On this page
- 1. Project Structure
- 2. Eloquent and Database Queries
- 3. Migrations
- 4. Validation and Authorization
- 5. Security Best Practices
- 6. Configuration and Environment
- 7. Queues and Background Work
- 8. Caching and Performance
- 9. Testing
- 10. Code Quality
- 11. Deployment
- 12. Keep Laravel Up to Date
- Common Mistakes I See in Laravel Projects
- Applying This to Your Project
- Need Help With a Laravel Project?
Laravel makes it easy to build something that works. Building something that still works, and is still pleasant to change, two years and fifty thousand users later is a different skill. Most of the Laravel projects I am asked to rescue did not fail because of Laravel; they failed because of a handful of habits that seemed harmless early on.
This guide collects the practices I use on production Laravel applications, from SaaS platforms to marketplaces, after over ten years of PHP and Laravel work. It applies to Laravel 11, 12 and later, and every section is practical: what to do, why, and the code that shows it.
- Keep controllers thin; put business logic in action or service classes.
- Validate in Form Requests and authorize with Policies, on every write.
- Turn on strict mode (
Model::shouldBeStrict()) in development to catch N+1 queries and silent bugs. - Eager load relationships; index every column you filter, sort or join on.
- Use raw SQL when it is clearer or faster, but always with bindings.
- Never edit a migration that has run in production; add a new one.
- Call
env()only inside config files. - Move anything slow (email, PDFs, API calls) to queues, run by Supervisor or a container.
- Test the paths that cost money when they break, including authorization.
- Automate quality: Pint, Larastan and tests in CI on every pull request.
1. Project Structure
Follow the framework's conventions
Laravel's conventions (where models, controllers, requests, jobs and policies live, how they are named) are a shared language. Any Laravel developer can open a conventional project and find their way around in minutes. Fight the conventions only with a very good reason.
Since Laravel 11, the application skeleton is slimmer: middleware, exception handling and routing are configured in bootstrap/app.php, and many files that used to exist by default are only created when you need them. Do not recreate the old structure out of habit.
Keep controllers thin
A controller should receive the request, call the code that does the work, and return a response. When a controller method grows past a screen, business logic has leaked into it.
// app/Actions/PlaceOrder.php
final class PlaceOrder
{
public function __construct(private PaymentGateway $payments) {}
public function handle(User $user, Cart $cart): Order
{
return DB::transaction(function () use ($user, $cart) {
$order = $user->orders()->create([...]);
$this->payments->charge($order);
OrderPlaced::dispatch($order);
return $order;
});
}
}
// Controller
public function store(StoreOrderRequest $request, PlaceOrder $placeOrder)
{
$order = $placeOrder->handle($request->user(), $request->cart());
return to_route('orders.show', $order);
}
Action classes like this are easy to test, reuse from a console command or a job, and find later.
Organise large applications by domain
When an application grows beyond a few dozen models, group code by business area (Billing, Catalog, Accounts) rather than having hundreds of files in flat folders. A modular structure like this also makes it possible to split a module into its own service later if it ever needs to, as I explain in choosing an enterprise tech stack.
The repository pattern: usually skip it
Wrapping every Eloquent model in a repository interface is a common pattern carried over from other frameworks. With Eloquent it mostly adds files, not value. Prefer:
- Query scopes for reusable conditions (
Order::paid()->recent()) - Dedicated query classes for complex reports
- Action classes for writes
A repository is worth it when you need to swap the underlying source, for example an external API and a local cache behind the same interface.
2. Eloquent and Database Queries
Turn on strict mode in development
// AppServiceProvider::boot()
Model::shouldBeStrict(! app()->isProduction());
This makes Laravel throw an exception when you lazy load a relationship (the cause of N+1 queries), fill an attribute that is not fillable, or read an attribute that was not selected. These are bugs you want to find on your laptop, not in production logs.
Eager load relationships
// N+1: one query for orders, then one per order for the customer
$orders = Order::latest()->take(50)->get();
// 2 queries in total
$orders = Order::with('customer:id,name')
->withCount('items')
->latest()
->take(50)
->get();
Select only the columns you need for large tables, and use withCount, withSum and withExists instead of loading whole relationships to count them.
Index what you query
Every column used in where, orderBy or a join on a large table needs an index, and multi-column filters often need a composite index in the right order. foreignId()->constrained() adds both the foreign key and its index. Check slow queries with EXPLAIN; most "Laravel is slow" problems are missing indexes.
Process large data sets in chunks
// Loads everything into memory: avoid on large tables
User::all()->each(...);
// Constant memory, safe even if rows are updated while iterating
User::where('active', true)->chunkById(500, function ($users) {
// ...
});
// Or as a lazy collection
foreach (User::where('active', true)->lazyById(500) as $user) {
// ...
}
Raw queries and joins: when and how
Eloquent is ideal for working with records. For reporting, aggregates across large tables and complex joins, the query builder or raw SQL is often clearer and much faster. The one rule that cannot be broken: user input goes in bindings, never in the SQL string.
// Safe: values are bound
$rows = DB::select(
'select c.id, c.name, sum(o.total) as revenue
from customers c
join orders o on o.customer_id = c.id
where o.paid_at between ? and ?
group by c.id, c.name
order by revenue desc
limit 20',
[$from, $to]
);
// Query builder join with a raw expression, still bound
$top = DB::table('orders')
->join('customers', 'customers.id', '=', 'orders.customer_id')
->selectRaw('customers.name, sum(orders.total) as revenue')
->whereRaw('orders.total > ?', [$minimum])
->groupBy('customers.name')
->get();
// Never: SQL injection
DB::select("select * from users where email = '$email'");
Column and table names cannot be bound, so if a user chooses a sort column, check it against a whitelist first.
Use transactions for multi-step writes
Wrap writes that must succeed or fail together in DB::transaction(). For operations that compete for the same rows (stock levels, account balances), lock them with lockForUpdate() inside the transaction.
Database configuration
- Keep MySQL
strictmode on (the default) so invalid data fails loudly instead of being silently truncated. - Use
utf8mb4so emoji and all languages store correctly. - Store money as integers (cents) or with a decimal cast, never as floats.
- Store timestamps in UTC; convert to the user's time zone only for display.
- On larger systems, configure separate read and write connections in
config/database.phpto use read replicas.
3. Migrations
- Never edit a migration that has run in production. Add a new migration to change the schema; edited migrations leave environments out of sync.
- One concern per migration, with a working
down()method where reversal is realistic. - Plan for zero-downtime changes on big tables: add a nullable column, deploy code that writes to both, backfill in a queued job, then make it required and remove the old column in a later release.
- Keep data fixes out of schema migrations where possible; use a one-off command or job you can run, test and monitor.
- Squash old migrations with
php artisan schema:dump --pruneonce there are hundreds, to keep fresh installs and test runs fast. - Use factories and seeders so every developer and the test suite can build realistic data in one command.
4. Validation and Authorization
Validate in Form Requests
class StoreProjectRequest extends FormRequest
{
public function authorize(): bool
{
return $this->user()->can('create', Project::class);
}
public function rules(): array
{
return [
'name' => ['required', 'string', 'max:120'],
'budget' => ['nullable', 'integer', 'min:0'],
'client_id' => ['required', Rule::exists('clients', 'id')
->where('team_id', $this->user()->team_id)],
];
}
}
// Only validated fields reach the model
$project = Project::create($request->validated());
Note the exists rule scoped to the user's team. Without it, a user could attach a record that belongs to someone else, one of the most common real-world Laravel vulnerabilities.
Authorize with Policies, not scattered if-statements
Put "can this user do this to this record" in a Policy and call it from controllers ($this->authorize('update', $project)), Blade (@can) and API resources. One place to read, one place to test.
5. Security Best Practices
- Mass assignment: define
$fillableon every model, and pass$request->validated(), never$request->all(). - Scope every lookup to the current user or tenant:
$request->user()->invoices()->findOrFail($id), notInvoice::findOrFail($id). - Output escaping:
{{ }}escapes; use{!! !!}only for HTML you have sanitised yourself. - CSRF protection stays on for all web forms; exclude only webhook endpoints that verify their own signatures.
- Passwords: use
Password::defaults()with sensible rules and rate-limit login attempts. - Encrypt sensitive columns with the
encryptedcast; use signed URLs for links that grant access without login. - Production settings:
APP_DEBUG=false, a uniqueAPP_KEY, HTTPS only, secure and HTTP-only cookies. - Dependencies: run
composer auditin CI and keep Laravel on a supported version. - Rate limiting on APIs, login, password reset and any endpoint that sends emails or SMS.
A fuller checklist is in web application security, and API-specific rules in API development.
6. Configuration and Environment
// config/services.php
'stripe' => [
'secret' => env('STRIPE_SECRET'),
],
// Anywhere else in the app
$secret = config('services.stripe.secret'); // correct
$secret = env('STRIPE_SECRET'); // returns null once config is cached
Never commit .env. Keep .env.example up to date with every key (without real values) so a new developer can get running quickly.
7. Queues and Background Work
Anything that does not need to finish before the user sees a response belongs in a queue: emails, PDFs, image processing, webhooks, third-party API calls and imports. Pages stay fast, and failures retry automatically.
Write jobs that can safely run twice
- Pass IDs or models to jobs, not large data blobs.
- Make jobs idempotent: check whether the work is already done before doing it, because retries and duplicate deliveries happen.
- Set
$tries,$backoffand$timeouton each job, and implementfailed()to alert or clean up. - Use
ShouldBeUniqueorWithoutOverlappingfor jobs that must not run in parallel for the same record. - Dispatch after the database commit (
afterCommit) so the job never runs before its data exists.
Run workers with Supervisor
[program:laravel-worker]
command=php /var/www/app/artisan queue:work redis --sleep=3 --tries=3 --backoff=10 --max-time=3600
numprocs=4
autostart=true
autorestart=true
stopasgroup=true
killasgroup=true
stopwaitsecs=3600
user=www-data
redirect_stderr=true
stdout_logfile=/var/www/app/storage/logs/worker.log
--max-time recycles workers so memory leaks cannot build up, and stopwaitsecs should be longer than your longest job so a deploy does not kill work in progress.
Queue workers in Docker
Run workers as a separate container (or service) from the web container, using the same image with a different command, for example php artisan queue:work --max-time=3600. Scale the number of worker containers instead of using numprocs, and give the container a stop grace period longer than your longest job. If you use Redis, run Horizon as the container's command to get a dashboard, metrics and automatic balancing between queues.
Restart workers on every deploy
Workers keep the old code in memory. Run php artisan queue:restart (or horizon:terminate) as part of every deployment.
Building With Laravel?
Tell me about your Laravel project, new build, upgrade or rescue, and I'll reply within 24 hours.
Discuss Your Project8. Caching and Performance
- Cache expensive, rarely changing data with
Cache::remember()and a clear key and expiry; clear it when the data changes. - Use Redis for cache, sessions and queues in production, not the file driver.
- Run
php artisan optimizeon deploy to cache config, routes, events and views. - Paginate every list; use
cursorPaginate()for very large or infinite-scroll lists. - Build front-end assets with Vite, and serve images in modern formats with correct sizes.
- Measure before optimising: Telescope, Debugbar, slow query logs or an APM tool. For very high-traffic apps, consider Laravel Octane.
More techniques in web performance optimization and building a scalable web application.
9. Testing
You do not need 100% coverage. You need tests on the things that cost money or trust when they break:
- Sign-up, login and password reset
- Payments, invoices and anything touching money
- Authorization: a test proving user A cannot see or change user B's data
- Core business rules (pricing, permissions, status changes)
- Integrations, using
Http::fake(),Queue::fake(),Mail::fake()andEvent::fake()
it("prevents users from viewing another team's invoices", function () {
$invoice = Invoice::factory()->create(); // belongs to another team
$user = User::factory()->create();
$this->actingAs($user)
->get(route('invoices.show', $invoice))
->assertNotFound();
});
Use Pest or PHPUnit with RefreshDatabase and factories, and run the suite on every pull request.
10. Code Quality
- Laravel Pint for consistent formatting, so code reviews discuss logic, not spaces.
- Larastan (PHPStan for Laravel) to catch type errors and undefined methods before runtime; raise the level over time.
- Type hints and return types everywhere, plus enums for fixed sets of values such as statuses.
- Rector to automate upgrades between PHP and Laravel versions.
- Clear names over comments:
$overdueInvoicesbeats$datawith a comment.
11. Deployment
- Run tests, Pint and Larastan in CI.
- Deploy atomically (Envoyer, Deployer, Forge, a container image), so users never see a half-updated app.
composer install --no-dev --optimize-autoloaderphp artisan migrate --forcephp artisan optimizephp artisan queue:restart- Make sure the scheduler runs every minute (
php artisan schedule:runfrom cron, orschedule:workin a container). - Monitor errors (Sentry, Flare, Bugsnag) and uptime, and keep tested, off-site database backups.
12. Keep Laravel Up to Date
Laravel releases a major version roughly once a year, and each version receives security fixes for a limited period. Upgrading one version at a time, soon after release, is quick; jumping four versions at once is a project. Laravel Shift and Rector make routine upgrades cheaper. If you are stuck on an old version, see my Laravel upgrade service.
Common Mistakes I See in Laravel Projects
- Business logic spread across controllers, models, Blade views and helpers
Model::all()on tables that have grown to millions of rows- N+1 queries on every list page
- Lookups not scoped to the current user, exposing other customers' data
env()calls in application code that break after config caching- Slow work done during the request instead of in a queue
- No tests on payments or permissions
- Running years behind on Laravel and PHP versions
Applying This to Your Project
For a small company website, some of this is more than you need: conventions, validation, escaping, config caching and backups cover most of the risk. For SaaS products, marketplaces and internal systems, all of it applies; for multi-tenant products specifically, see multi-tenant SaaS in Laravel and building a SaaS with Laravel.
Need Help With a Laravel Project?
I build and maintain Laravel applications for companies in the US, UK, Canada, Australia and Europe, and I review and fix existing Laravel codebases that have become slow, fragile or hard to change. See Laravel development and legacy PHP rescue, read how to hire a Laravel developer, or tell me about your project.