Pametna optimizacija Laravela
Laravel je poznat po svojoj fleksibilnosti, ali i po brzini kojom Laravel aplikacije rade. Na početku, svaka Laravel aplikacija deluje brza. Rute se izvršavaju odmah, podaci stižu bez primetnog čekanja i sve izgleda jednostavno. Međutim, kako sistem raste, tako se u aplikaciju dodaju modeli, relacije, API endpointi, nove tabele, pozadinski poslovi i slično. Tada počinjete polako da osećate da se vaša Laravel aplikacija usporava. Naravno, to usporavanje se ne primeti odjednom, već postepeno.
Na primer, stranica koja je ranije radila trenutno sada čeka pola sekunde, zatim sekundu, pa onda dve i tako redom. Upiti koji su nekada vraćali rezultate u deliću milisekunde počnu da troše više vremena, a vaš web server počne da troši više resursa nego što bi trebalo.
Najzanimljivije je to što problemi u stvari ne dolaze iz neke greške koju ste napravili prilikom izrade vaše Laravel aplikacije. Mnogo češće dolaze iz sasvim običnih stvari. Na primer, iz toga kako se Eloquent ponaša pod opterećenjem, kako se učitavaju relacije, koliko podataka putuje kroz aplikaciju i koliko puta baza zapravo dobija isti upit. Tek kada zavirite ispod haube, postaje jasno da je većina usporenja čista matematika: više podataka znači više memorije, više objekata znači više procesorskog vremena, a više upita znači sporiji odziv.
Kao što znate, Eloquent u Laravelu služi kao sloj između aplikacije i baze podataka: umesto da pišete SQL, radite sa modelima i relacijama, a ORM se stara da sve prevede u upite i vrati rezultate kao PHP objekte. To je sve lako i čitljivo, ali ima i svoju cenu, jer svaki rezultat se hidrira u objekat sa svim kolonama koje je upit vratio. Ako je vaš upit preširok, u memoriju stižu i polja koja stranica uopšte ne koristi.
Kod malih tabela to prolazi bez posledica, ali na većim skupovima podataka višak bajtova postaje realno opterećenje: više I/O-a prema bazi, više serijalizacije pri JSON odgovorima, više CPU-a za transformacije i više memorije za kratko živeće objekte koje PHP ipak mora da alocira i oslobodi.
Najtipičnije mesto gde se to vidi su administrativne liste i API endpointi koji vraćaju samo za prikaz nekoliko polja. Ako se podrazumevano povlači sve, aplikacija će sigurno to sporije uraditi jer kolone ne završavaju ni u viewu ni u JSONu.
Zbog toga je već u ranoj fazi važno da razdvojite podatke koji su vam zaista potrebni od podataka za koje samo mislite da će vam biti potrebni, i da upite suzite samo na one kolone koje zaista planirate da koristite u konkretnom ekranu ili odgovoru.
Tek tada ima smisla preći na dublje obrasce za liste, masovne obrade i eksport.
Eloquent i podaci koji nikada ne budu korišćeni
Već smo rekli da User::all() vuče sve kolone. Važno je da razumete kako to utiče na realne delove aplikacije. Zamislite da imate admin tabelu u kojoj se prikazuju ID, ime, email i datum kreiranja.
U pozadini tabela users sadrži još desetine kolona: tokeni za integracije, JSON podešavanja, putanje do avatara, metapodatke, pa čak i neka istorijska polja koja više niko ne koristi.
Kada admin strana povuče sve, PHP mora da alocira memoriju za svako polje svakog reda i da sve to pretvori u Eloquent objekte. To troši RAM i vreme, i pre nego što dođete do renderovanja Blade šablona.
Zato se na listama uvek isplati selektivni upit, čak i kada razlike na malom uzorku deluju zanemarljivo:
// lista za admin: treba samo ono što se prikazuje
$users = User::query()
->select('id', 'name', 'email', 'created_at')
->latest('id')
->paginate(50);
Dve važne napomene pored selekcije kolona: uvek navedite ORDER BY (ovde latest('id')) kako MySQL ne bi radio skupa sortiranja nasumično, i koristite paginate() umesto get() da ne biste vukli ceo skup odjednom na ekran. Paginacija automatski dodaje LIMIT i OFFSET, što značajno rasterećuje bazu i PHP.
Kod masovne obrade, chunk() je bezbedan izbor, ali obratite pažnju na stabilno sortiranje. Ako obrada menja podatke u istoj tabeli (na primer, postavlja flag “obrađeno“), bez stabilnog redosleda može doći do preskakanja ili dupliranja. Tipičan obrazac izgleda ovako:
User::query()
->select('id', 'email')
->where('newsletter_opt_in', true)
->orderBy('id') // stabilan redosled je ključan
->chunk(1000, function ($chunk) {
foreach ($chunk as $user) {
// slanje notifikacije ili priprema eksport fajla
}
});
Ako treba da strimujete ogroman rezultat kao CSV bez opterećenja memorije, Laravelov cursor() je još efikasniji jer koristi generator:
$response = response()->streamDownload(function () {
$handle = fopen('php://output', 'w');
fputcsv($handle, ['ID', 'Email']);
foreach (User::select('id', 'email')->orderBy('id')->cursor() as $user) {
fputcsv($handle, [$user->id, $user->email]);
}
fclose($handle);
}, 'users.csv');
return $response;
Ovim pristupom nikada nemate više od jednog reda u memoriji, a korisnik vaše aplikacije dobija kontinuirani download bez čekanja da se sve pripremi unapred.
N+1 problem i eager loading. Kako prepoznati i kako osigurati da se ne vrati
with() smo već primenili, ali hajde da razložimo tipične zamke koje mogu da vas sačekaju. Najpre, kako da prepoznate N+1 ako niste gledali SQL log? Jedan praktičan znak je da stranica radi očekivano sa desetak redova, ali drastično uspori sa stotinama. Još direktniji put je Laravel Debugbar ili Laravel Telescope: oba jasno pokažu broj upita po zahtevu i ponavljanje obrazaca.
Eager loading ne mora biti izbor tipa sve-ili-ništa; možete ga usloviti i suziti. Na primer, umesto da učitate sve porudžbine korisnika, učitajte samo one plaćene i ograničite polja:
$users = User::query()
->with(['orders' => function ($q) {
$q->select('id', 'user_id', 'total', 'paid_at') // samo neophodne kolone
->whereNotNull('paid_at')
->latest('paid_at');
}])
->select('id', 'name') // ne zaboravite: i ovde suzite kolone
->get();
Još jedan obrazac: kada renderujete listu i treba vam samo „broj komentara“ ili „broj narudžbina“, withCount() je dizajnirano za upravo to:
$posts = Post::query()
->select('id', 'title')
->withCount(['comments as approved_comments_count' => function ($q) {
$q->where('status', 'approved');
}])
->latest('id')
->paginate(20);
U view-u više nema poziva relacije; postoji konkretno polje approved_comments_count, što čini renderovanje trivijalno i brzo.
Ako imate kolekciju koja možda nije učitala relaciju (recimo da kolekcija dolazi iz više različitih grana koda), loadMissing() osigurava da se relacija dohvati samo ako nedostaje:
$orders->loadMissing('customer', 'items');
Tako se izbegava i dupli upit i rizik da nečiji kod zaboravi with().
I za kraj joše jedan napomena: kad relacija sama po sebi vraća mnogo podataka (npr. porudžbine sa stavkama), nemojte se ustručavati da suzite selekciju i na „grandchildren“ relacije:
$orders = Order::query()
->with([
'customer:id,name,email',
'items' => function ($q) {
$q->select('id', 'order_id', 'sku', 'qty')
->with(['product:id,sku,name']);
}
])
->select('id', 'customer_id', 'total', 'status')
->latest('id')
->paginate(50);
Ovakva struktura dramatično smanjuje količinu vraćenih kolona i broja upita, a ostavlja bogat model podataka u memoriji.
Bezbedno uklanjanje paketa koji više nemaju ulogu
Skidanje paketa bez dobre pripreme može da razbije aplikaciju, pa je zato potrebno da ispoštujete neophodan redosled. Prvo: prođite kroz composer.json i identifikujte kandidate. Drugo: proverite stvarnu upotrebu klasa i fasada iz tih paketa. U Monolitima je korisno pokrenuti pretragu koda po prostoru imena paketa (npr. Vendor\Package\). Ako ništa ne nađete, proverite i registracije u config/app.php i eventualno config/package.php.
Pre uklanjanja napravite grananje u repozitorijumu i pustite testove (ako postoje). Zatim:
composer remove vendor/package
php artisan config:clear
php artisan cache:clear
php artisan optimize
Ako aplikacija koristi auto-discovery, obratite pažnju na extra.laravel.providers i extra.laravel.aliases u composer.json samog paketa; ponekad ostanu stale reference. Nakon uklanjanja, startujte aplikaciju lokalno, prođite ključne tokove i pratite logove. Ako sve radi, tek tada spojite grananje na glavnu granu i deploy-ujte.
Kao poslednji korak, uključite optimizovani autoloader u produkciji:
composer install --no-dev --optimize-autoloader
Ovo generiše klas-mapu i ubrzava autoload bez pretraživanja cele strukture direktorijuma.
Keširanje u praksi – više od config:cache i route:cache
Keš konfiguracije i ruta skida stalan trošak pri svakom request-u, ali najveću uštedu često daje keš poslovnih podataka i skupih upita. Osnovni obrazac sa Cache::remember() je dobar početak, ali dve stvari su presudne: invalidacija i opseg ključeva.
Ako keširate listu kategorija globalno pod ključem categories, promena jedne kategorije mora da osveži taj keš. Pristup preko događaja modela je jedno od mogućih rešenja: u Category modelu dodajte observer koji čisti keš kada se kategorija izmeni.
// App\Providers\EventServiceProvider.php
protected $observers = [
\App\Models\Category::class => \App\Observers\CategoryObserver::class,
];
// App\Observers\CategoryObserver.php
class CategoryObserver
{
public function saved(Category $category): void
{
Cache::forget('categories');
}
public function deleted(Category $category): void
{
Cache::forget('categories');
}
}
Za višetenantske sisteme ili filtere po korisniku, ključ treba da odražava kontekst. Jedan način je da ugradite namespacing u ključ:
$key = sprintf('tenant:%d:categories', tenant()->id);
$categories = Cache::remember($key, 300, function () {
return Category::select('id', 'name')->orderBy('name')->get();
});
Ako koristite Redis, tagovani keš omogućava fino granulisano čišćenje: sve što pripada tenant-u 5 čisti se jednim pozivom.
$categories = Cache::tags(['tenant:5', 'categories'])
->remember('list', 300, fn () => Category::forTenant(5)->get());
// kasnije, nakon izmene
Cache::tags(['tenant:5', 'categories'])->flush();
Iskustveno, najveći dobitak je u keširanju skupih agregacija (brojevi, sumiranja, statistike) i skupih upita sa više JOIN-ova. Ako rezultat mora da bude svež, koristite kratak TTL (npr. 30–60 sekundi). To je sasvim dovoljno da se baza malo odmori, a korisnik praktično ne primeti kašnjenje.
Queue u realnim uslovima: worker-i, retry, idempotentnost
Slanje mejlova kroz ->queue() je najlakši korak, ali da bi sistem bio stabilan pod opterećenjem potrebno je da postavite nekoliko pravila. Prvo, workere držite pod nadzorom preko Supervisor-a ili systemd-a, kako se prestanak rada ne bi provukao neprimećen.
Konfiguracija za Supervisor izgleda ovako:
[program:laravel-worker]
process_name=%(program_name)s_%(process_num)02d
command=php /var/www/app/artisan queue:work redis --sleep=1 --tries=3 --max-time=3600
autostart=true
autorestart=true
numprocs=4
redirect_stderr=true
stdout_logfile=/var/log/supervisor/laravel-worker.log
Drugo, jobovi treba da budu idempotentni, tako da njihovo višestruko izvršavanje ne napravi štetu. Na primer, ako šaljete webhook eksternom servisu, koristite jedinstveni event_id i čuvajte ga u tabeli poslatih događaja; ako isti job dođe drugi put, prekinite ga:
public function handle(): void
{
if (SentWebhook::where('event_id', $this->eventId)->exists()) {
return; // već poslato
}
$this->sender->send($this->payload);
SentWebhook::create(['event_id' => $this->eventId]);
}
Treće, failed_jobs tabela može da posluži kao dobar izvor informacija. Podesite handler koji obaveštava Slack ili e-mail kada job padne više puta. Brza reakcija na kvar u pozadini drži vašu aplikaciju pouzdanom, a korisnik i dalje dobija brze odgovore jer HTTP deo nije blokiran.
Kako izvući maksimum iz PHP-a i OPcache-a
Nadogradnja na noviji PHP je nešto što se smatra obaveznim kada je reč o performansama bez diranja koda. O tome svo već pisali u jednom od naših ranijih tekstova Šta je novo u PHP 8.3. Pored same verzije, OPcache podešavanja prave razliku u produkciji. Uobičajena, zdrava polazna tačka:
opcache.enable=1
opcache.enable_cli=0
opcache.memory_consumption=256
opcache.interned_strings_buffer=16
opcache.max_accelerated_files=20000
opcache.validate_timestamps=0 ; na produkciji
opcache.revalidate_freq=0
validate_timestamps=0 podrazumeva da ne menjate fajlove direktno na serveru. Ako imate CI/CD, to i jeste slučaj; svaka nova verzija koda dolazi kao novi deploy, a OPcache ne troši vreme proveravajući stamp-ove fajlova. Ako ipak menjate fajlove ručno (ne preporučuje se), ostavite validate_timestamps=1 i postavite revalidate_freq=2 ili sličnu vrednost, svesni manjeg, ali stalnog troška.
U Laravel svetu ovo se lepo dopunjuje sa php artisan optimize tokom deploy-a, jer se generišu keširane mape klasa, rute, konfiguracije i pogledi, pa OPcache ima manje posla oko inicijalnog punjenja.
Kako sve ovo izgleda u jednoj realnoj ruti
Za kraj, da zaokružimo priču jednim reprezentativnim primerom: tipična index ruta koja vraća listu porudžbina sa kupcem i stavkama, brzo i sigurno.
public function index(Request $request)
{
$perPage = (int) min(max($request->integer('per_page', 25), 1), 100);
$orders = Order::query()
->select('id', 'customer_id', 'total', 'status', 'created_at')
->with([
'customer:id,name,email',
'items' => function ($q) {
$q->select('id', 'order_id', 'sku', 'qty', 'price')
->with(['product:id,sku,name']);
},
])
->when($request->filled('status'), fn ($q) => $q->where('status', $request->string('status')))
->latest('id')
->paginate($perPage);
return Inertia::render('Orders/Index', [
'orders' => $orders,
]);
}
Ovde je primenjeno sve što smo obradili u ovom tekstu: selektivne kolone, eager loading sa sužavanjem, stabilno sortiranje, paginacija sa bezbednom granicom per_page, i izbegavanje povlačenja nepotrebnih podataka. Ako se ova lista često otvara sa istim filterima, možete je obložiti kešom sa kratkim TTL-om ili tagovima po korisniku/tenantu. Ako se posle kreiranja porudžbine šalje e-mail ili eksportuje faktura, to ide u queue i ne blokira prikaz.
EXPLAIN, indeksi i razlika između brzog i sporog upita
Čak i kada je Eloquent pravilno podešen i podaci se selektuju selektivno, može da se desi da upit i dalje radi sporo. Tada je najbolji način da se otkrije uzrok, da pogledate u plan izvršavanja. MySQL u pozadini ne izvršava upit onako kako ga je developer napisao, već pokušava da pronađe optimalan način da dođe do rezultata: da li će koristiti indeks, da li mora da skenira celu tabelu, da li pravi privremenu tabelu, da li sortira na disku ili u memoriji.
Zato se EXPLAIN koristi kao prozor u to kako baza „razmišlja“. Najjednostavniji oblik izgleda ovako:
EXPLAIN SELECT id, name FROM users WHERE email = 'neki@email.rs';
Ako postoji indeks na koloni email, videćete da MySQL pregledava samo nekoliko redova. Ako ga nema, rezultat će biti „ALL“, što znači da baza ne može bolje: mora da pregleda sve korisnike i traži poklapanje.To je prilično loše rešenje kada tabela ima stotine hiljada korisnika.
Zato indeks na email rešava problem:
CREATE INDEX idx_users_email ON users (email);
Posle kreiranja, isti EXPLAIN pokazuje da MySQL koristi indeks, što znači da se ne pretražuje cela tabela nego samo deo koji indeks pokriva. Efekat je posebno vidljiv na API pozivima koji rade mnogo ovakvih provera, recimo tokom autentifikacije ili validacije korisnika.
Sortiranje je druga oblast u kojoj se drastično vidi razlika između optimizovanog i sporog upita. Kada korisnici sortiraju listu proizvoda po ceni, datumu kreiranja ili popularnosti, MySQL mora da rasporedi rezultate. Ako za tu kolonu postoji indeks, sortiranje je brzo. Ako ga nema, MySQL mora da napravi privremeni skup rezultata i izvrši tzv. filesort: operaciju koja troši više resursa i može da se prebaci na disk ako je dataset prevelik.
— sporije
SELECT id, name FROM products ORDER BY price;
— brže
CREATE INDEX idx_products_price ON products (price);
SELECT id, name FROM products ORDER BY price;
Kada plan izvršavanja otkrije da MySQL pravi privremene tabele (Using temporary) ili filesort (Using filesort), to je najčešći znak da indeks rešava stvar. Nije poenta indeksirati svaku kolonu, ali je važno indeksirati one po kojima se često filtrira ili sortira.
Za složenije analize koristi se EXPLAIN ANALYZE, jer on neće samo pokazati plan, već i realno vreme izvršavanja i broj skeniranih redova. To pomaže kada se čini da je indeks na mestu, ali upit i dalje spor. Moguće je da upit ne koristi indeks jer ga oblik WHERE uslova blokira. Tipičan primer je upit sa funkcijom nad kolonama:
SELECT * FROM users WHERE YEAR(created_at) = 2023;
Pošto MySQL mora da pozove YEAR nad svakim redom, indeks nad created_at ne pomaže.
Kada se uslov preformuliše na opseg, indeks ponovo postaje koristan:
SELECT * FROM users
WHERE created_at >= '2025-01-01'
AND created_at < '2025-01-01';
Na velikim tabelama razlika između jednog i drugog upita nije procentualna, nego višestruka, a nekad i 10x ili 100x brže.
Paginacija koja se ne zaglavljuje na velikim podacima
Laravel paginacija koristi LIMIT i OFFSET. Sve izgleda sasvim u redu, dok tabela ne postane velika. Ako se koristi OFFSET 200000, MySQL i dalje mora da preskoči 200.000 redova, pa tek onda vrati narednih 50. Kod sporih diskova ili slabijih servera, ovo osećaju i korisnici vaše aplikacije.
Brže rešenje je paginacija preko kursora. Umesto preskakanja, API pamti id poslednjeg reda i nastavlja od njega:
$orders = Order::query()
->select('id', 'total', 'created_at')
->where('id', '>', $request->integer('last_id', 0))
->orderBy('id')
->limit(50)
->get();
Ovaj pristup se odlično ponaša na tabelama koje svakodnevno rastu. Upit postaje stabilan: MySQL zna tačno gde da nastavi i ne gubi vreme na skeniranje prethodnih redova. Kod API-ja koji vrate veliki broj stavki (npr. listanje logova, porudžbina ili korisničkih događaja) kursorska paginacija praktično uklanja uska grla.
Kada je keš najvredniji i kako ga zadržati pod kontrolom
Keš je najbrže rešenje kada baza počne da trpi previše upita. Nije neobično da se isti podaci traže desetine puta u kratkom periodu, recimo tabela kategorija, liste država, vrste proizvoda ili cene koje se retko menjaju. Ako je rezultat već u kešu, Laravel preskače bazu u potpunosti:
$categories = Cache::remember('categories:list', 3600, function () {
return Category::select('id', 'name')->orderBy('name')->get();
});
Međutim, pravi izazov je osvežavanje keša. Ako admin promeni ime kategorije, korisnici treba da vide novu vrednost, a to zapravo znači da keš mora da se obriše. Najefikasnije rešenje je automatsko čišćenje kroz Eloquent događaje ili model observatore.
class CategoryObserver
{
public function saved(Category $category)
{
Cache::forget('categories:list');
}
public function deleted(Category $category)
{
Cache::forget('categories:list');
}
}
Tako aplikacija uvek ima tačne podatke, a keš radi tiho i neprimetno.
Prava moć keša se oseća kod skupljih upita, onih koji rade agregacije (SUM, COUNT, GROUP BY) ili složenih JOIN operacija. Umesto da se svaki put troše resursi baze, rezultat se uzima iz memorije, a baza se dira samo kada se podaci zaista promene.
Queue sistem i kako ga koristiti bez iznenađenja u produkciji
Queue ne ubrzava sam server, već korisničko iskustvo. Poslovi koji mogu da traju: slanje e-mail notifikacija, generisanje PDF faktura, slanje webhook događaja drugim sistemima, ne treba da blokiraju HTTP odgovor. Korisnik želi da dobije potvrdu odmah, a posao u pozadini može da krene i sekundu kasnije.
Laravel omogućava da se gotovo svaka radnja prebaci u pozadinu jednostavnim dispatch().
Umesto:
Mail::to($user->email)->send(new WelcomeMail($user));
bolje je:
dispatch(new SendWelcomeMail($user))->afterResponse();
Najvažnije je obezbediti da radnici rade non-stop, što znači Supervisor, systemd ili sličan menadžer procesa. U produkciji, bez ovoga je samo pitanje vremena kada će queue da stane, a korisnici da misle da mailovi ne rade.
Posebno je važno da jobovi budu otporni na greške. Ako API kome šaljete podatke privremeno padne, job ne sme da nestane, već da se ponovi:
public function handle()
{
try {
$this->client->push($this->payload);
} catch (\Exception $e) {
$this->release(30); // pusti ponovo za 30 sekundi
return;
}
}
A za poslove koji ne smeju biti izvršeni dvaput (npr. fakturisanje, naplata), potrebno je obezbediti idempotentnost, odnosno obeležiti šta je već izvršeno i prekinuti duplikate.
Kada brzina dolazi iz samog PHP-a
Čak i bez diranja koda, prelazak sa starijih verzija PHP-a na novije donosi primetno ubrzanje. PHP 8.1 i 8.2 obrađuju više zahteva u istom vremenu, koriste manje memorije i efikasnije rade sa tipizacijom. Ako aplikacija ima veliki promet, ovo je najlakši način da se dobije prostor za disanje.
OPcache donosi dodatnu razliku: umesto da PHP svakog puta čita i kompajlira fajlove, gotov bytecode se čuva u memoriji. Pravilna konfiguracija znači da isti kod radi brže bez promene jedne linije.
Zaključak
Laravel može da izgleda spor samo kada radi više nego što treba. Kada se podaci selektuju pažljivo, kada se relacije učitavaju unapred, kada se keš koristi pametno i kada spori poslovi odu u pozadinu, aplikacija se vraća na nivo brzine koji se od nje očekuje.
Sve ove tehnike su deo svakodnevnog razvoja i nisu ni trikovi, niti napredna optimizacija, već osnovna higijena velikih sistema. U trenucima kada broj korisnika raste, upravo one prave razliku između aplikacije koja se muči i aplikacije koja ostaje stabilna, brza i spremna za sledeći korak.
