
18 minutes read
25 Laravel best practices, tips, and tricks
Table of contents
- → Introduction
-
→
Laravel best practices, tips, and tricks
- → Keep Laravel up to date
- → Keep packages up to date
- → Keep your project tested to prevent critical bugs
- → Stick to the default folder structure
- → Use custom form requests for complex validation
- → Use single action controllers to keep the code organized
- → Use middlewares instead of repeating code
- → Use policies for authorization
- → Keep migrations up to date
- → Use anonymous migrations to avoid conflicts (Laravel 8.37+)
- → Use the down() method correctly for rollbacks
- → Use Eloquent’s naming conventions for table names
- → Prevent N+1 issues with eager loading
- → Use Eloquent’s strict mode to prevent performance issues and bugs
- → Use the new way of declaring accessors and mutators
- → Use dispatchAfterResponse() for long-running tasks
- → Use queues for even longer running tasks
- → Lazily refresh your database before each test
- → Make use of factories to help you with fake data and tests
- → Test against the production stack whenever it’s possible
- → Use database transactions to rollback changes after each test
- → Don’t waste API calls, use mocks
- → Prevent stray HTTP requests to identify slow tests
- → Don’t track your .env file
- → Don’t track your compiled CSS and JavaScript
-
→
FAQ
- → What are the most important Laravel best practices in 2025?
- → How do I prevent N+1 queries in Eloquent?
- → Should I use Single Action Controllers vs. resource controllers?
- → How do I safely modify columns in Laravel 11/12?
- → What is the difference between Http::fake and preventStrayRequests?
- → When should I use dispatchAfterResponse vs. queues?
- → Conclusion
Introduction
For most Laravel projects, the best practices come down to two points:
- Stick to the defaults.
- Defer as much work as possible to the framework.
Whether you are running Laravel 12, 11, or 10, I will show how I improve any codebase with clear tips and examples.
By the way, in addition to this article, I recommend these books to keep leveling up with Laravel:
- Battle ready Laravel by Ash Allen. This will teach you many new things to take your Laravel apps to the next level.
- Consuming APIs with Laravel by Ash Allen. If you thought you knew REST APIs, see what Ash has to say.
- Mastering Laravel Validation Rules by Aaron Saray and Joel Clermont. Learn to ensure data integrity with practical examples.
Laravel best practices, tips, and tricks
Keep Laravel up to date
Keeping Laravel up to date provides the following benefits:
- Improved security: Laravel regularly releases security fixes.
- Better performance: updates often include faster load times and more efficient code.
- New features and functionality: these are why we use and love Laravel.
- Compatibility with the latest official and community packages.
If Laravel updates scare you, it is likely because your codebase is not tested. You might fear a major update will break your code in a way that is hard to sort out. If that is the case, testing is a best practice you should adopt. More on that below.
Keep packages up to date
Access to dozens of packages from the official Laravel ecosystem and thousands of community packages is what makes my work easier.
But the more packages you use, the more points of failure you can face.
Regularly running composer update
is one of the easiest best practices to adopt and goes a long way toward a more secure codebase.
It is the same as in the previous section: if your code is not well tested, unexpected regressions can occur. Do not worry. The next sections give you a starting point to level up on that front. 💪
Keep your project tested to prevent critical bugs
Writing automated tests is a vast topic that many developers skip.
It is also one of the few best practices that ensures reliability.
Here are the benefits of a good test suite:
- Fewer bugs.
- Happier customers.
- Happier employers.
- Confident developers. You will not fear breaking something when you return to the project later.
- New hires can be productive from day one, especially if you follow Laravel’s guidelines. Changed some code? No problem. Just run
php artisan test
, see what you broke, fix, and repeat.
Being able to make a project much more stable thanks to automated testing will do wonders for your career.
Laracasts provides free testing courses to help you get started. One with PHPUnit, the industry standard, and one with Pest, which offers a modern, fluent API for testing in PHP.
- PHP Testing Jargon.
- Pest From Scratch (this is the one I recommend).
Stick to the default folder structure
Do you know why you are using a framework?
- It frames your work with a set of guidelines you can follow so every teammate is on the same page.
- It provides many complex, tedious, and battle‑tested features for free, so you can focus on what is specific to your project.
So, is it a best practice to stick to Laravel’s default project structure?
- Convenience: Laravel’s default way of doing things is documented. When you return to a project weeks or months later, you will thank your past self for this.
- Working with teammates is much easier. They know Laravel, just like you. Use this shared knowledge to move the project forward instead of reinventing the wheel every time.
When should you not stick to the defaults?
When the size of your project actually requires doing things differently.
Read more on architecture best practices.
Use custom form requests for complex validation
The main reasons to use custom form requests are:
- Reusing validation across multiple controllers.
- Offloading code from bloated controllers.
Creating custom form requests is as simple as running this Artisan command:
php artisan make:request StorePostRequest
Then, in your controller, just type‑hint it:
use App\Http\Requests\StorePostRequest; class PostController { public function store(StorePostRequest $request) { $validated = $request->validated(); Post::create($validated); // } }
Custom form requests can also include extra logic beyond validation. For example, you can use them to normalize input data before it reaches your controller:
public function prepareForValidation(): void { $this->merge([ 'slug' => Str::slug($this->title), ]); }
This method will generate a slug from the title before validation occurs.
They can also manage authorization checks if you feel like Policies are overkill.
Use single action controllers to keep the code organized
Sometimes, even when I follow all the best practices, controllers become too big.
Laravel provides single action controllers to help.
Instead of multiple actions (index, create, store, show, etc.), a single action controller has just one.
To create one, use:
php artisan make:controller ShowPostController --invokable
This creates a controller with only one action named __invoke
(learn more about the __invoke magic method).
Then, in your routes, you can do this instead:
use App\Http\Controllers\PostController; // [tl! --] use App\Http\Controllers\ShowPostController; // [tl! ++] Route::get('/posts/{post}', [PostController::class, 'show']); // [tl! --] Route::get('/posts/{post}', ShowPostController::class); // [tl! ++]
This is a subjective best practice. I like it for focused actions, and you can decide what fits your team.
Use middlewares instead of repeating code
Middlewares in Laravel let you filter or modify the current request. Common uses:
- Checking for required permissions.
- Checking the user’s language and changing the locale.
Laravel ships with many middlewares for authentication, rate limiting, and more.
Once your middleware does what it is supposed to do, you can either block the request or let it go through.
public function handle(Request $request, Closure $next): Response { if (! $request->user()->hasEnoughTokens()) { abort(403); } return $next($request); }
A middleware can be attached to many routes, which helps you prevent code duplication.
Learn more about Laravel middleware.
Use policies for authorization
Using policies for authorization in Laravel is key to an organized and maintainable app. Here are three reasons I rely on policies:
- Reuse authorization logic across multiple controllers.
- Offload code from bloated controllers.
- Make it easy to find and update authorization rules in app/Policies.
A simple example:
// app/Policies/PostPolicy.php public function update(User $user, Post $post): bool { return $user->id === $post->user_id; } // app/Http/Controllers/PostController.php public function update(Request $request, Post $post) { $this->authorize('update', $post); // ... }
Keep migrations up to date
Migrations let you describe your database schema in plain PHP.
See them like phpMyAdmin, but with code instead of a user interface.
This helps everyone on the team replicate the same environment locally and keep history in Git.
That is also how you deploy to new environments, like staging and production, without exporting a database from somewhere else.
Sometimes developers edit the database directly instead of creating a migration. This is bad and makes life harder for other developers. There is nothing more annoying than asking teammates on Slack for a database dump.
Read more about how migrations can improve any project.
Use anonymous migrations to avoid conflicts (Laravel 8.37+)
Anonymous migrations help you avoid class name conflicts. For example, you can create many “update_posts_table” migrations without errors. Anything that reduces friction is good.
They were introduced in Laravel 8.37 and are used by default in modern stubs.
php artisan make:migration UpdatePostsTable
A typical anonymous migration looks like this:
<?php use Illuminate\Support\Facades\Schema; use Illuminate\Database\Schema\Blueprint; use Illuminate\Database\Migrations\Migration; return new class extends Migration { public function up(): void { Schema::table('posts', function (Blueprint $table) { // ... }); } public function down(): void { // ... } };
If you are on older 8.x projects that still have named classes, you can change the class to return new class
and add a semicolon at the end to get the same effect.
Use the down() method correctly for rollbacks
The down()
method (used by the php artisan migrate:rollback
command) is run when you need to roll back changes to your database.
Some people use it, some do not.
If you belong to the people who use it, make sure your down()
method is implemented correctly.
It must do the opposite of the up()
method.
use Illuminate\Support\Facades\Schema; use Illuminate\Database\Schema\Blueprint; use Illuminate\Database\Migrations\Migration; return new class extends Migration { public function up(): void { Schema::table('posts', function (Blueprint $table) { // The column was a boolean, but we want to switch to a datetime. $table->dateTime('is_published')->nullable()->change(); }); } public function down(): void { Schema::table('posts', function (Blueprint $table) { // When rolling back, restore the column to its previous state. $table->boolean('is_published')->default(false)->change(); }); } };
Note: when modifying a column, re‑state any modifiers you want to keep (unsigned, default, comment). In Laravel 11/12, many databases use native schema operations for column changes, so previous DBAL requirements are reduced. See the docs on migrations.
Use Eloquent’s naming conventions for table names
Laravel’s naming conventions for tables are simple and a best practice that will help your team.
If you use Artisan, the framework does it for you: php artisan make:model Post --migration --factory
.
If you cannot use those commands, here is a quick overview:
- For a
Post
model, name your tableposts
. Use the plural form (comments
forComment
,replies
forReply
, etc.). - For a pivot table linking a
Post
to aTag
(for example,post_tag
):- Use both names.
- Singular form.
- Alphabetical order.
Read the docs on many‑to‑many relationships for more details.
Prevent N+1 issues with eager loading
I have talked about many best practices, and there is more.
Ever heard about N+1 problems? Eager loading is how you avoid them.
Say you display a list of 30 posts with their author:
- Eloquent will make one query for the 30 posts.
- Then 30 more queries for each author, because the
author
relationship is lazy loaded when you call$post->author
.
The fix is simple. Use with()
. You will go from 31 queries to only 2.
Post::with('author')->get();
To make sure you do not have N+1 problems, you can throw exceptions when any relationship is lazy loaded. Apply this only in your local environment.
use Illuminate\Database\Eloquent\Model; if (! app()->isProduction()) { Model::preventLazyLoading(); }
Use Eloquent’s strict mode to prevent performance issues and bugs
Eloquent strictness helps you catch issues during development by throwing exceptions when:
- A relationship is lazy loaded.
- You try to set attributes that are discarded.
- You access missing attributes.
Enable strictness in App\Providers\AppServiceProvider::boot()
and only outside production:
use Illuminate\Database\Eloquent\Model; public function boot(): void { if (! app()->isProduction()) { Model::preventLazyLoading(); Model::preventSilentlyDiscardingAttributes(); Model::preventAccessingMissingAttributes(); } }
Note: Model::shouldBeStrict()
still works, but the current docs highlight the specific methods above.
Use the new way of declaring accessors and mutators
The new way of declaring accessors and mutators arrived in Laravel 9.
Here is how you should declare them now:
use Illuminate\Database\Eloquent\Casts\Attribute; class Pokemon { public function name(): Attribute { $locale = app()->getLocale(); return Attribute::make( get: fn ($value) => $value[$locale], set: fn ($value) => [$locale => $value], ); } }
You can cache expensive values (via Attribute::shouldCache()
):
use Illuminate\Database\Eloquent\Casts\Attribute; public function someAttribute(): Attribute { return Attribute::make( get: fn () => /* compute value */ 42, )->shouldCache(); }
The old way looked like this:
class Pokemon { public function getNameAttribute(): string { $locale = app()->getLocale(); return $this->attributes['name'][$locale]; } public function setNameAttribute($value): string { $locale = app()->getLocale(); return $this->attributes['name'][$locale] = $value; } }
Use dispatchAfterResponse() for long-running tasks
Let us use a simple example. You have a contact form. Sending an email may take one or two seconds.
What if you could delay this until after the user gets the response?
That is what dispatchAfterResponse()
does:
SendContactEmail::dispatchAfterResponse($input);
Or, if you prefer an inline job:
dispatch(function () { // Do something. })->afterResponse();
It works after the HTTP response if your web server uses FastCGI. Use it for sub‑second tasks, like sending mail. You do not need a worker for these.
Use queues for even longer running tasks
Imagine you need to process images uploaded by users.
If you process every image right away, this will happen:
- Your server will burn.
- Users will wait in front of a loading screen.
This is not good UX, and we can change that.
Laravel has a queue system that runs tasks sequentially or with limited parallelism.
To manage jobs through a UI, use Laravel Horizon.
Small note on web performance: Google replaced FID with INP as a Core Web Vital. Keep long tasks off the request path to help INP.
Lazily refresh your database before each test
When you can use fake data in your local environment, a great option is to test against a fresh database for each run.
Use the Illuminate\Foundation\Testing\LazilyRefreshDatabase
trait in your tests/TestCase.php
.
There is also a RefreshDatabase
trait, but the lazy one is more efficient, as migrations for unused tables will not be run during testing.
Make use of factories to help you with fake data and tests
Factories make testing much easier.
You can create one with php artisan make:factory PostFactory
and add fake data to each column:
namespace Database\Factories; use App\Models\User; use Illuminate\Database\Eloquent\Factories\Factory; class PostFactory extends Factory { public function definition(): array { return [ 'user_id' => User::factory(), 'title' => fake()->sentence(), 'slug' => fake()->slug(), 'content' => fake()->paragraphs(5, true), 'description' => fake()->paragraph(), ]; } }
Factories create everything you need when writing tests.
Here is one in action:
public function test_it_shows_a_given_post(): void { $post = Post::factory()->create(); $this ->get(route('posts.show', $post)) ->assertOk(); }
Test against the production stack whenever it’s possible
In production, you probably use something other than SQLite, like MySQL. Or Redis instead of the array cache driver.
So why not use them when running your tests too? Bugs can appear only with those backends, and tests are supposed to help you catch them before they happen in production.
I value reliability and accuracy over raw test speed here.
Use database transactions to rollback changes after each test
In one of my projects, I need to create a database filled with real data from CSV files on GitHub.
It takes time, and I cannot refresh my database before every test. It is too slow.
So when my tests alter the data, I want to roll back the changes to keep the database in its initial state. You can do this with the Illuminate\Foundation\Testing\DatabaseTransactions
trait in your base test case class (tests/TestCase.php
).
Don’t waste API calls, use mocks
In Laravel, mocks help you avoid wasting API calls during tests and hitting rate limits.
Say we use Twitter’s API. In our container, we have a Client
class.
When running the test suite, we want to avoid unnecessary calls to the real API. The best way is to swap the client in the container with a mock.
$mock = $this->mock(Client::class); $mock ->shouldReceive('getTweet') ->with('Some tweet ID') ->andReturn([ 'data' => [ 'author_id' => '2244994945', 'created_at' => '2022-12-11T10:00:55.000Z', 'id' => '1228393702244134912', 'edit_history_tweet_ids' => ['1228393702244134912'], 'text' => 'This is a tweet', ], ]);
Learn more about mocking in the docs.
Prevent stray HTTP requests to identify slow tests
Here is a great tip if you want to make sure all HTTP requests in your tests are faked. Use Http::preventStrayRequests()
from the HTTP facade.
It will throw an exception if any HTTP request without a fake response is executed.
Use this in a single test or for your entire suite.
use Illuminate\Support\Facades\Http; Http::preventStrayRequests();
If you need to allow a few real requests, you can pair this with Http::allowStrayRequests()
or use Http::fake([...])
to allow‑list specific hosts or patterns. See the HTTP client docs.
Don’t track your .env file
Your .env
file contains sensitive information.
Please, do not track it.
Make sure it is in your .gitignore
.
Most of the time, data leaks happen inside jobs.
A password manager is a better solution for sharing credentials.
Also, commit a .env.example
file so teammates know which keys they need. See environment configuration.
Don’t track your compiled CSS and JavaScript
Your CSS and JavaScript are generated from sources in resources/css
and resources/js
.
With Vite, the build output is public/build
.
If you build on deploy, add this to .gitignore
:
/public/build
FAQ
What are the most important Laravel best practices in 2025?
The biggest wins are simple. Keep Laravel and packages updated, build a solid testing foundation, enable Laravel Eloquent strictness, use eager loading to prevent N+1, and move slow work to queues or dispatchAfterResponse
. These practices make a Laravel app safer, faster, and easier to maintain.
How do I prevent N+1 queries in Eloquent?
Use eager loading with with()
on your Laravel queries, and enable Model::preventLazyLoading()
in non‑production. This keeps related data from being loaded one row at a time. See eager loading for examples.
Should I use Single Action Controllers vs. resource controllers?
Both work in Laravel. I use single action controllers for focused behaviors and resource controllers when I want a full CRUD shape. Pick one approach per feature so your Laravel project stays consistent.
How do I safely modify columns in Laravel 11/12?
When you change a column, re‑state any modifiers you want to keep, like unsigned
, default
, or comment
. Laravel 11/12 use more native schema operations, so DBAL needs are lower than in 9/10. See modifying columns.
What is the difference between Http::fake and preventStrayRequests?
Http::fake
lets you provide fake responses for specific URLs or patterns in Laravel tests. Http::preventStrayRequests()
blocks any HTTP call that does not match a fake, which prevents unplanned real network calls and helps you find slow tests. See the HTTP client.
When should I use dispatchAfterResponse vs. queues?
Use dispatchAfterResponse
for very short, one‑off tasks that can run right after the HTTP response, like sending a confirmation email. Use Laravel queues for heavier or many tasks, like processing images or syncing large data sets, and manage them with Horizon.
Conclusion
- Keep Laravel and packages updated, and rely on the defaults when you can.
- Build a test suite with factories and database refresh or transactions, and prevent stray HTTP requests.
- Enable Eloquent strictness and use eager loading to avoid N+1 problems.
- Defer slow work with
dispatchAfterResponse
and move heavy tasks to queues and Horizon.
Did you like this article? Then, keep learning:
- Detailed guide on fixing a common Laravel error relevant to best practices
- Fix the common Laravel "419 Page Expired" error with quick practical tips
- Explore Laravel 12 books that advance your understanding and skills in Laravel
- Step-by-step guide to upgrading Laravel versions safely and effectively
- Deep dive into Laravel’s migration system, similarly essential for best practices
- Learn about essential Laravel security best practices to protect your apps
- Master testing in Laravel with best practices to improve reliability and stability
- Discover how to use Tailwind CSS effectively with Laravel projects
0 comments