In 30 seconds
Laravel is a PHP framework that gives you routing, a database layer (Eloquent), validation, queues, scheduling and authentication out of the box. You describe what should happen for each URL, and Laravel handles the plumbing around it. Laravel 13 (March 2026) needs PHP 8.3+ and is a light upgrade from 12.
Why I use it
- I use it because one framework covers the whole backend of a real product: API, admin, queues, scheduled jobs, emails and auth, with conventions every new developer already knows.
- I use it because Eloquent and FormRequests let me ship features fast without giving up structure, so the code still reads well after two years.
- I use it because queues and the scheduler are built in, which matters for the heavy parts of commerce: ERP sync, emails, segments and reports.
- I use it because its testing tools (HTTP tests, factories, fakes) make backend tests cheap enough that the team actually writes them.
How it works
01The request lifecycle
Every request enters through public/index.php. Laravel boots the application (service providers register bindings and config), then passes the request through a stack of middleware, like a series of checkpoints at an airport. The router matches the URL and HTTP method to a route, which calls a controller method. The controller returns something (a model, an array, a Resource, a view), and Laravel converts it into an HTTP response that travels back out through the same middleware. In Laravel 11+ this wiring lives in bootstrap/app.php: you register routes, middleware and exception handling there instead of a separate Kernel class. Knowing this order explains most bugs: if auth fails, look at middleware; if data is wrong, look at the controller and below.
- Service provider: a class that registers things in the service container at boot.
- Service container: Laravel's dependency injection system; it builds classes and their dependencies for you.
- Middleware: code that runs before or after the controller (auth, throttling, locale, CORS).
02Routing, middleware and thin controllers
Routes map URLs to code. Group them by audience and attach middleware to the group, so every route inside inherits authentication, rate limits or a locale. Keep controllers thin: their job is to read the validated input, call a service that does the real work, and return a response. Business rules (how a price is calculated, when an order may be cancelled) belong in services or actions, and data access belongs in models. This split, controller to service to model, means you can call the same logic from an API endpoint, an Artisan command or a queued job without copying code.
// routes/api.php
use App\Http\Controllers\Api\OrderController;
use Illuminate\Support\Facades\Route;
Route::middleware(['auth:sanctum', 'throttle:api'])
->prefix('v1')
->group(function () {
Route::get('orders', [OrderController::class, 'index']);
Route::post('orders', [OrderController::class, 'store']);
Route::get('orders/{order}', [OrderController::class, 'show']);
});
03Validation with FormRequests, output with API Resources
Validate at the edge. A FormRequest class holds the rules and the authorize() check for one action; if validation fails, Laravel returns a 422 response with field errors before your controller runs. Inside the controller, $request->validated() gives you only the fields that passed, which protects against mass assignment. On the way out, an API Resource turns a model into the exact JSON shape your clients expect. This decouples your database columns from your public contract: you can rename a column without breaking the mobile app. Resources also make conditional fields easy, such as including relations only when they were loaded.
class StoreOrderRequest extends FormRequest
{
public function authorize(): bool
{
return $this->user()->can('create', Order::class);
}
public function rules(): array
{
return [
'items' => ['required', 'array', 'min:1'],
'items.*.product_id' => ['required', 'integer', 'exists:products,id'],
'items.*.qty' => ['required', 'integer', 'between:1,99'],
'note' => ['nullable', 'string', 'max:500'],
];
}
}
04Eloquent, relationships and eager loading
Eloquent is Laravel's ORM: each model class maps to a table, and relationships (hasMany, belongsTo, belongsToMany, morphMany) are methods on the model. The classic trap is the N+1 problem: you load 50 orders, then touch $order->customer in a loop, and Eloquent runs 50 extra queries. Eager loading with with() fetches the relation in one extra query. In development, call Model::preventLazyLoading() so lazy loads throw an exception and you catch N+1 early. Use withCount() for counts, select only the columns you need for large lists, and paginate everything a user can scroll. Laravel 13 also lets you configure many model properties with PHP attributes, but the classic properties still work.
// AppServiceProvider::boot()
Model::preventLazyLoading(! app()->isProduction());
// One query for orders, one for customers, one for items
$orders = Order::query()
->with(['customer:id,name', 'items.product:id,name,price'])
->withCount('items')
->where('status', OrderStatus::Paid)
->latest()
->paginate(20);
return OrderResource::collection($orders);
05Queues, the scheduler and authentication
Anything slow or unreliable (emails, SMS, payment callbacks, exports, ERP sync) goes into a queued job: the user gets a fast response and a worker process does the work in the background, with retries. The scheduler replaces a pile of cron entries with one: cron runs php artisan schedule:run every minute, and your schedule is defined in routes/console.php. For authentication, Sanctum is the default choice: cookie sessions for your own SPA on the same top-level domain, plus simple personal access tokens for mobile apps. Passport is a full OAuth2 server; choose it when third parties need OAuth flows or you already depend on its token model. Socialite adds Google, Apple and other social logins on top of either.
// routes/console.php
use Illuminate\Support\Facades\Schedule;
Schedule::command('subscriptions:expire')
->dailyAt('02:00')
->withoutOverlapping()
->onOneServer();
Schedule::job(new SyncPricesFromErp)->hourly();
// crontab: * * * * * cd /var/www/app && php artisan schedule:run >> /dev/null 2>&1
06Production tips for the Laravel 13 era
Run on PHP 8.3 or newer, and use enums, readonly properties and typed signatures everywhere. On deploy, run php artisan optimize to cache config, routes, events and views, and never call env() outside config files, because cached config ignores it. Run migrations as a separate deploy step. Keep queue workers under a process manager and restart them on every deploy with php artisan queue:restart, otherwise they keep running old code. Use Telescope or Pail locally for debugging, and structured logs plus error tracking in production. Laravel 13 also ships a first-party AI SDK and JSON:API resources; adopt them when they solve a real problem, not because they are new.
- Deploy: composer install --no-dev --optimize-autoloader, migrate --force, optimize, queue:restart.
- Set APP_DEBUG=false in production; debug pages leak secrets.
- Prefer database transactions around multi-step writes, and dispatch jobs afterCommit.
Use it when
- Business applications with a database at the centre: e-commerce, CRMs, booking, back offices.
- One API that must serve a website, a mobile app and an admin panel.
- Teams that want strong conventions, fast onboarding and batteries included.
- Products with lots of background work: emails, imports, exports, scheduled syncs.
Skip it when
- Thousands of long-lived connections per process or heavy streaming, where Node or Go fit more naturally (or use Reverb alongside Laravel).
- Tiny single-purpose functions where a serverless handler is simpler than a full framework.
- A team with no PHP experience and a strong existing TypeScript backend.
Alternatives
- NestJS
TypeScript, modules and DI on Node; great when the whole stack is TypeScript.
- Symfony
More explicit and configurable PHP framework; many Laravel components are built on it.
- Django
Python equivalent with a built-in admin; good when the team lives in Python.
- Ruby on Rails
The framework that inspired much of Laravel; similar productivity, Ruby ecosystem.
Best practices
- Keep controllers thin; put business logic in services or single-purpose action classes.
- Validate every input with FormRequests and return data through API Resources.
- Eager load relations and enable preventLazyLoading outside production.
- Use enums for statuses and types instead of magic strings.
- Wrap multi-step writes in DB::transaction and dispatch jobs after commit.
- Cache config and routes on deploy, and restart queue workers every release.
- Write feature tests for every endpoint that moves money or changes state.
Common mistakes
- Fat controllers with hundreds of lines of business logic that cannot be reused or tested.
- N+1 queries hidden in Blade views or Resources.
- Using $request->all() with create(), opening the door to mass assignment.
- Calling env() in application code, which returns null once config is cached.
- Forgetting to restart workers after deploy, so jobs run old code against a new schema.
Deep dive
07The service container, scopes and long-running workers
Classic PHP-FPM boots Laravel for every request and throws everything away afterwards, so sloppy state rarely hurts. Queue workers and Octane (FrankenPHP, Swoole or RoadRunner) boot the application once and then serve thousands of requests or jobs from the same memory. That changes the rules. A binding registered with bind() gives a fresh object every time it is resolved. singleton() gives one object for the life of the process, which is right for stateless clients such as an HTTP client for a payment gateway. scoped() gives one object per request or per job and is reset between them, which is what you want for anything holding the current user, tenant or locale. Putting request data into a singleton or a static property is the classic Octane bug: one customer briefly sees another customer's context.
// AppServiceProvider::register()
// Stateless client: safe to share for the whole process
$this->app->singleton(PaymentGateway::class, fn () => new GatewayClient(
config('services.gateway.key'),
));
// Holds per-request data: reset for every request and every queued job
$this->app->scoped(CurrentTenant::class, fn () => new CurrentTenant());
// Cheap builder: a new instance on every resolve
$this->app->bind(ReportBuilder::class);
08Eloquent on large tables: chunking, lazy collections and strict mode
Order::all() on a table with two million rows loads two million models into memory and kills the process. For batch work use chunkById(), which pages by primary key and stays correct even when you update the rows you are iterating (plain chunk() with offsets skips rows in that case). For exports, lazyById() returns a LazyCollection that streams rows with near-constant memory. Watch whereHas() on big tables: it compiles to a correlated EXISTS subquery that can be slow without the right composite index, and sometimes a join or a whereIn with a subselect is much faster. Model::shouldBeStrict() turns on three guards at once: prevent lazy loading, throw when you set an attribute that is not fillable instead of silently dropping it, and throw when you read an attribute that was not selected.
// Backfill without loading everything
Order::query()
->where('status', OrderStatus::Paid)
->whereNull('invoice_number')
->chunkById(500, function ($orders) {
foreach ($orders as $order) {
GenerateInvoice::dispatch($order->id);
}
});
// Stream an export with near-constant memory
foreach (Order::query()->select(['id', 'total', 'created_at'])->lazyById(1000) as $order) {
$csv->insertOne([$order->id, $order->total, $order->created_at]);
}
09Race conditions: atomic updates, row locks and distributed locks
Two customers buy the last item at the same moment. Both requests read stock = 1, both pass the check, both decrement, and stock becomes -1. Reading, checking in PHP and then writing is never safe under concurrency. The cheapest fix is an atomic conditional update: let the database check and change in one statement, and look at the number of affected rows. When you need several reads and writes to stay consistent, use a transaction with lockForUpdate(), which holds a row lock until commit; keep such transactions short and always lock rows in the same order to avoid deadlocks. DB::transaction() accepts an attempts count and retries on deadlock. For work that spans processes, such as one ERP import at a time across servers, use Cache::lock() with a Redis or database store. A unique index remains the last line of defence.
DB::transaction(function () use ($productId, $qty) {
$affected = Product::whereKey($productId)
->where('stock', '>=', $qty)
->decrement('stock', $qty);
if ($affected === 0) {
throw new OutOfStockException($productId);
}
}, attempts: 3);
// One import at a time across all servers (needs a shared cache store)
Cache::lock('erp-import', 600)->block(5, function () {
app(ErpImporter::class)->run();
});
10Hardening a Laravel API
Most Laravel security problems are configuration and habits, not framework bugs. Define named rate limiters per audience: generous for signed-in users, strict for anonymous traffic, very strict for OTP and password reset, keyed by user, phone and IP together. Scope nested route bindings with scopeBindings() so /shops/1/orders/99 fails when order 99 belongs to another shop. Use encrypted casts for sensitive columns such as tax ids or tokens, and remember that APP_KEY rotation is supported through APP_PREVIOUS_KEYS, so old values still decrypt while new ones use the new key. Use signed URLs for links in emails (unsubscribe, download) instead of inventing your own tokens. Keep Gate::before super-admin shortcuts narrow, because they bypass every policy. Finally, run composer audit in CI to catch packages with known vulnerabilities.
// AppServiceProvider::boot()
RateLimiter::for('api', function (Request $request) {
return $request->user()
? Limit::perMinute(120)->by('user:'.$request->user()->id)
: Limit::perMinute(30)->by('ip:'.$request->ip());
});
RateLimiter::for('otp', fn (Request $request) => [
Limit::perMinute(1)->by('otp-phone:'.$request->input('phone')),
Limit::perDay(20)->by('otp-ip:'.$request->ip()),
]);
// routes: ->middleware('throttle:otp')
Real-world scenarios
The orders list got slow after a year of growth
Problem
The admin orders page took 200 ms at launch and now takes 6 seconds. The table has grown to a few million rows and customers are complaining about the API too.
How to solve it
Measure before changing anything. Locally, open Telescope or Debugbar and count queries; in production, enable the database slow query log. Usually you find two things: an N+1 inside a Resource and a filter with no index. Add eager loading and preventLazyLoading, then add a composite index that matches the query, for example (status, created_at) when you filter by status and sort by date. Replace offset pagination on deep pages with cursorPaginate(), because OFFSET 200000 still scans the skipped rows. Select only the columns the list shows. Re-measure, then add a feature test that asserts the query count so the problem cannot come back.
A scheduled job runs twice after adding a second server
Problem
The team added a second app server behind the load balancer. Since then, customers receive the daily reminder SMS twice and the nightly report is generated twice.
How to solve it
Both servers run the same cron entry, so both run schedule:run every minute. Add onOneServer() to every task that must run once, and make sure the default cache store is shared, such as Redis, the database or Memcached, because the lock lives there; a file or array cache gives each server its own lock and protects nothing. Add withoutOverlapping() for long tasks. Alternatively, run the scheduler on one dedicated worker host only. Then make the jobs themselves idempotent, for example by recording that the reminder for that subscription and date was sent, so a future misconfiguration cannot double-send.
Upgrading a live product from Laravel 12 to 13
Problem
A busy e-commerce API runs Laravel 12 on PHP 8.2. The business wants the upgrade done without a freeze on feature work or any downtime.
How to solve it
Start with PHP, because Laravel 13 needs 8.3 or newer: upgrade PHP on staging, run the full test suite and PHPStan, then roll the new PHP version out to production while still on Laravel 12. Next, on a branch, bump laravel/framework and first-party packages in composer.json, read the official upgrade guide item by item, and fix deprecations. Run tests, deploy to staging, run queue workers and the scheduler there for a day, and check logs. Ship with the normal zero-downtime deploy and restart workers. Keep the branch short-lived and rebase daily so feature work never stops.
Cheat sheet
- Show versions, drivers and cache status
php artisan about- List routes with their middleware
php artisan route:list -v --except-vendor- Clear every cached file after a bad deploy
php artisan optimize:clear- Model with migration, factory and policy
php artisan make:model Order -mfp- Inspect a table's columns and indexes
php artisan db:table orders- Tail application logs live
php artisan pail --filter="OrderController"- See scheduled tasks and next run times
php artisan schedule:list- Turn on all Eloquent safety guards
Model::shouldBeStrict(! app()->isProduction());- Fast pagination for deep or infinite lists
Order::orderBy('id')->cursorPaginate(50);- Check dependencies for known vulnerabilities
composer audit
In my real projects
- Built a restaurant-chain ordering platform as one Laravel API (about 330 route lines) with controllers split into Shared, Ui, Mobile and Admin, so web and app reuse the same core logic.
- On a pet-supplies platform (Laravel 12) the backend has about 110 service classes over domain models and 95 backend test files; ERP sync runs as scheduled, feature-flagged jobs with health alerts.
- Built a gym-chain CRM almost solo on Laravel 13 and PHP 8.3, with ordered, non-overlapping scheduled jobs (unfreeze, then expire, then activate) and Passport for app auth.
- Run Laravel queue workers under supervisor and the scheduler through cron on production servers.
Questions & answers
Q1Explain the Laravel request lifecycle.
The request hits public/index.php, which loads the autoloader and creates the application. Service providers register and boot, so the container knows how to build everything. The request then passes through global middleware, the router matches a route, and route middleware runs (auth, throttle, bindings). The controller method is resolved through the container, so its dependencies are injected. Whatever it returns is converted into a response, which travels back through middleware, and finally terminable middleware runs after the response is sent. In Laravel 11 and later, routes, middleware and exception handling are configured in bootstrap/app.php.
Q2What is the N+1 problem and how do you avoid it in Eloquent?
N+1 happens when you run one query for a list and then one more query per row to fetch a relation, for example loading 50 orders and then each order's customer lazily. That is 51 queries instead of two. In Eloquent you fix it with eager loading: Order::with('customer')->get(), or load() on an existing collection, and withCount() for counts. To catch it early I enable Model::preventLazyLoading() outside production, so any lazy load throws. In code review I also look at Resources and views, because that is where hidden relation access usually lives.
Q3Sanctum or Passport: which do you pick?
Sanctum for most products. It handles cookie-based session auth for a first-party SPA on the same top-level domain and simple personal access tokens for mobile apps, with abilities as lightweight scopes. Passport is a full OAuth2 server built on league/oauth2-server; it is the right choice when external developers or partner systems need standard OAuth flows like authorization code with PKCE or client credentials, or when an existing system already depends on it. Passport brings more moving parts: keys, clients, token tables and refresh handling. If nobody outside your company needs OAuth, Sanctum is simpler and easier to secure.
Q4Where do you put business logic in a Laravel app?
Not in controllers and not scattered across models. Controllers read validated input and return a Resource. The logic lives in service classes or single-purpose actions, like PlaceOrder or CalculateDeliveryFee, that take plain data and models and can be called from a controller, a job or a command. Models keep relationships, casts, scopes and small domain helpers. For complex rules I use value objects, for example a Money or Quantity type, so the rules are tested in isolation. This keeps controllers short, makes logic reusable across web, mobile and admin, and makes unit testing straightforward.
Q5How do you run queues and the scheduler in production?
The scheduler needs one cron entry that runs php artisan schedule:run every minute; the schedule itself lives in code, with withoutOverlapping and onOneServer for jobs that must not run twice. Queue workers run php artisan queue:work under a process manager such as supervisor or systemd, which restarts them if they crash. I set tries, backoff and timeouts per job, make jobs idempotent, and watch the failed_jobs table. On every deploy I run queue:restart so workers pick up new code. With Redis as the driver, Horizon gives a dashboard and balancing across queues.
Q6What changed in Laravel 13 and is it worth upgrading?
Laravel 13 shipped in March 2026. The main practical change is the PHP floor: it requires PHP 8.3 or newer. It adds wider PHP attribute support for configuring models and other classes, a first-party AI SDK for text generation, embeddings and agents, and first-party JSON:API resources. The official upgrade guide describes it as a relatively small upgrade from 12. For an active project I upgrade within a few months: bump PHP first, update first-party packages, run the test suite, and fix deprecations. Staying on supported versions matters mainly for security fixes.
Senior-level questions
S1How would you scale a Laravel application for a large traffic increase?
First make app servers stateless: sessions, cache and locks in Redis, files in object storage, so you can add servers behind a load balancer. Cache config and routes, use OPcache, and consider Octane once the code is safe for long-lived processes; it removes boot cost but punishes leaked state. Move slow work to queues and scale workers separately from web nodes. On the database side, add the right indexes first, then read replicas using Laravel's read and write connection config with the sticky option, so a user reads their own writes. Cache expensive reads with explicit invalidation. Put static assets behind a CDN. The trade-off is operational complexity: each step adds moving parts, so measure where time goes before adding the next one.
S2What changes when you move a Laravel app to Octane?
The app boots once per worker and serves many requests from the same memory, so throughput rises and latency falls because there is no per-request bootstrap. The price is that state now leaks. Singletons that capture the request, config or authenticated user keep the first request's values; static properties and arrays grow forever; packages that assume a fresh process can break. I audit service providers and replace request-dependent singletons with scoped bindings or resolve them lazily, avoid static caches, and run load tests watching memory per worker. Octane can restart workers after a number of requests as a safety net, but that hides leaks rather than fixing them. For CRUD apps with a slow database, Octane gains little; for CPU-light, high-volume APIs it can be significant.
S3How do you prevent overselling or double-spending under concurrent requests?
Never read a value, check it in PHP and write it back. For a single counter like stock or a balance, I use an atomic conditional update, decrement where stock is at least the quantity, and treat zero affected rows as failure; it holds no long locks and scales well. When a business operation reads and writes several rows, I use a short transaction with lockForUpdate on the rows involved, locking in a consistent order, and let DB::transaction retry on deadlock. Pessimistic locks are simple but reduce throughput on hot rows; optimistic locking with a version column suits low-conflict edits like admin forms. For external side effects such as payments, an idempotency key plus a unique index guarantees one charge even if the client retries.
S4How do you keep a large Laravel codebase modular?
Laravel does not force modules, so you choose a level. The lightest is domain subfolders inside app, such as Orders, Catalog and Loyalty, each with its actions, models and events, plus a rule that modules talk through actions or events, not each other's models. The next level is local Composer packages with their own service providers, which make boundaries explicit and reusable across projects, at the cost of more wiring. Whichever you pick, enforce it with tools: Deptrac or Pest architecture tests that fail CI when a module imports another's internals. I avoid splitting into services until a domain has a different scaling or release need, because a well-bounded monolith is far cheaper to run and refactor.
Glossary
- Service container
- Laravel's dependency injection system. It builds classes, resolves their constructor dependencies and decides whether to reuse instances.
- Service provider
- A class that registers bindings, routes, events or config with the application during boot.
- Facade
- A static-looking shortcut, like Cache::get(), that forwards calls to a real object resolved from the container.
- Scoped binding
- A container binding that lives for one request or one queued job and is reset afterwards.
- Octane
- A Laravel package that keeps the application booted in memory between requests using FrankenPHP, Swoole or RoadRunner.
- Route model binding
- Laravel automatically turning an id in the URL into a loaded model, returning 404 if it does not exist.
- Cast
- A model rule that converts a column when reading and writing, for example to an enum, a date, JSON or an encrypted value.
- Policy
- A class grouping authorization rules for one model, such as whether a user may update or delete an order.
- Cursor pagination
- Paging by the last seen key instead of an offset; fast on huge tables, but no jumping to page 500.
Keep learning
Have a product in mind?Let's build it.
Tell me about your idea, your product or your team. I usually reply within one working day.