Why the First Hour of a Laravel Project Decides the Next Two Years
A fresh laravel new gives you a working application in seconds. That's the trap. The defaults are tuned for a gentle learning curve, not for a codebase that ten engineers will still be maintaining in 2028. Everything Laravel lets you get away with early — dynamic properties, silent type juggling, lazy-loaded queries, fat controllers — becomes a tax you pay on every future change.
The engineers who ship reliable Laravel applications share a common habit: they spend the first hour of a new project turning off the forgiving behavior and turning on the strictness. The idea that "strict by default" is not pedantry has become a widely-shared philosophy in the modern Laravel community — because it's how you make the framework catch your bugs before your users do.
This guide walks through the concrete, copy-pasteable configuration that makes a new Laravel project strict, self-documenting, and safe to refactor. Each section explains not just what to add, but why it changes the class of bugs you can ship.
The full list we'll cover:
declare(strict_types=1)in every file — no silent type coercionModel::shouldBeStrict()— catch N+1 queries, missing attributes, and silent discardsModel::unguard()— trade mass-assignment guards for typed DTOs (a deliberate choice)finalclasses by default andreadonlywherever possible- PHPStan / Larastan at the maximum level
- Rector with an aggressive rule set for automated upgrades and cleanups
- Pint configured with
declare_strict_types,strict_comparison,final_class, and more - Actions instead of fat controllers
CarbonImmutable— dates that can't mutate under you
declare(strict_types=1): The One Line That Ends an Entire Class of Bugs
PHP will happily accept "5 apples" where an int is expected, coerce it to 5, and move on without a word. That's convenient until a malformed request quietly becomes a valid-looking number and corrupts your data. declare(strict_types=1) disables this coercion for the file it appears in: pass a string to an int parameter and you get a TypeError immediately, at the boundary, instead of a wrong result three layers deep.
<?php
declare(strict_types=1);
namespace App\Services;
final class InvoiceCalculator
{
public function total(int $quantity, float $unitPrice): float
{
return $quantity * $unitPrice;
}
}Because the directive is per-file, "add it everywhere" really means every single PHP file — models, controllers, config, tests, migrations. Doing this by hand is a losing battle, which is exactly why we let Pint and Rector enforce it automatically (covered below). But the mental model matters: strict types turn PHP from a language that guesses into a language that verifies.
A common objection: "won't this break third-party packages?" No — strict_types is scoped to the file that declares it and to the call site, not to the callee's internals. Your strict files calling a loose vendor function are fine; the strictness applies to the argument types you pass.
Model::shouldBeStrict(): Make Eloquent Fail Loudly Instead of Silently
This is the single highest-leverage line you can add to a Laravel project. In your AppServiceProvider::boot():
use Illuminate\Database\Eloquent\Model;
public function boot(): void
{
Model::shouldBeStrict(! $this->app->isProduction());
}shouldBeStrict() is a convenience that enables three separate guards at once:
preventLazyLoading()— throws when a relationship is accessed without being eager-loaded. This is your automatic N+1 detector. The most common performance killer in Laravel apps becomes a hard error in development instead of 400 silent queries in production.preventSilentlyDiscardingAttributes()— throws when you try to set a model attribute (via mass assignment orfill()) that isn't$fillable/isn't a real column. No more typos like$user->emialsilently doing nothing.preventAccessingMissingAttributes()— throws when you read an attribute that wasn't loaded from the database (e.g. youselect('id')then read->name). Catches subtle bugs where a partially-hydrated model looks fine but returnsnull.
Notice the ! $this->app->isProduction() argument. The pattern is strict in local, staging, and CI; lenient in production. You want these to explode in your face during development and block your test suite — but you don't want a newly-introduced lazy load to take down a live page for a customer. Your tests catch it first.
If you'd rather be explicit, you can enable the individual guards and even control them per-model:
Model::preventLazyLoading(! $this->app->isProduction());
Model::preventSilentlyDiscardingAttributes(! $this->app->isProduction());
Model::preventAccessingMissingAttributes(! $this->app->isProduction());Model::unguard(): A Deliberate Trade, Not a Shortcut
Model::unguard() disables Laravel's mass-assignment protection globally, so you no longer maintain $fillable or $guarded arrays on every model:
use Illuminate\Database\Eloquent\Model;
public function boot(): void
{
Model::unguard();
Model::shouldBeStrict(! $this->app->isProduction());
}This is controversial, so be honest about the trade-off. Mass-assignment guards exist to stop this class of bug:
// Attacker adds is_admin=1 to the form POST
User::create($request->all()); // 😱 without guards, they're now an admin Unguarding is only safe if you never pass raw request input straight into a model. In the modern-Laravel style, you don't — you validate into a Form Request, map validated data into a typed Data Transfer Object (e.g. via spatie/laravel-data), and only ever hand explicit, known fields to Eloquent:
$data = CreateUserData::from($request->validated());
User::create([
'name' => $data->name,
'email' => $data->email,
]); The mental shift: $fillable is a weak, boilerplate-heavy defense; explicit field mapping through validated DTOs is a strong one. If your team can't commit to that discipline, keep the guards. If you can, unguard() removes a pile of ceremony without adding real risk. It's a choice — make it consciously, not by copying a snippet.
Pair this with preventSilentlyDiscardingAttributes() (from shouldBeStrict()): even unguarded, a typo'd attribute name still throws. The two features complement each other.
final Classes by Default and readonly Wherever Possible
Two keywords, one philosophy: make the compiler enforce your design decisions.
final by default
Marking a class final forbids inheritance. That sounds restrictive until you internalize the principle: inheritance is a design decision that should be opt-in, not accidental. When every class is final unless you deliberately open it, you get:
- Freedom to refactor internals without worrying about unknown subclasses depending on them
- A clear signal of intent — an open class says "I was designed to be extended"
- Encouragement toward composition over inheritance, which is easier to test and reason about
final class SendWelcomeEmail
{
public function __construct(
private readonly Mailer $mailer,
) {}
public function handle(User $user): void
{
$this->mailer->to($user->email)->send(new WelcomeEmail($user));
}
} You don't have to type final by hand — Pint's final_class rule and Rector's ADD_FINAL rules do it for you. Note: don't final your Eloquent models if you rely on packages that mock or extend them, and be aware some testing setups need non-final classes to mock. In practice, final your Actions, Services, DTOs, and Value Objects aggressively; be pragmatic about framework-touching classes.
readonly wherever possible
A readonly property can be assigned exactly once (in the constructor) and never mutated afterward. PHP 8.2's readonly class makes every property immutable in one keyword:
final readonly class Money
{
public function __construct(
public int $amount,
public string $currency,
) {}
public function add(Money $other): self
{
return new self($this->amount + $other->amount, $this->currency);
}
} Immutable objects can't be in an inconsistent state, are safe to share across your app, and eliminate a whole category of "who changed this?" bugs. DTOs and Value Objects should be readonly almost without exception.
PHPStan / Larastan at the Maximum Level
Static analysis reads your code without running it and tells you where the types don't add up — undefined variables, impossible conditions, methods called on null, mismatched return types. Larastan is the Laravel-aware extension for PHPStan that understands Eloquent magic, facades, and container bindings.
composer require --dev larastan/larastan Create phpstan.neon at the project root and crank the level to the maximum:
includes:
- vendor/larastan/larastan/extension.neon
parameters:
level: 10 # max. Start here on a new project.
paths:
- app
- config
- database
- routes
checkModelProperties: true
checkOctaneCompatibility: true
treatPhpDocTypesAsCertain: false PHPStan levels run 0–9, plus max (currently level 10 in recent releases). On a brand-new project, start at the top. There's no legacy debt yet, so the highest level costs you nothing and locks in the standard forever. On an existing project you'd ratchet up level by level — but that's precisely the pain a new project gets to skip.
./vendor/bin/phpstan analyse Wire it into CI so a type error fails the build. Combined with declare(strict_types=1), PHPStan at max level turns a huge portion of "runtime surprises" into "the pipeline is red before merge." That's the entire point: move failures left, from production to your terminal.
Rector with Aggressive Rules: Automated Refactoring That Never Sleeps
Rector is a tool that rewrites your code according to rules — upgrading PHP syntax, modernizing patterns, and cleaning up dead code automatically across the entire codebase. On a new project, you configure it once to enforce a modern baseline and then let it keep the code there forever.
composer require --dev rector/rector driftingly/rector-laravel A rector.php with an aggressive but sane rule set:
<?php
declare(strict_types=1);
use Rector\Config\RectorConfig;
use Rector\Set\ValueObject\SetList;
use Rector\Set\ValueObject\LevelSetList;
use RectorLaravel\Set\LaravelSetList;
return RectorConfig::configure()
->withPaths([
__DIR__ . '/app',
__DIR__ . '/config',
__DIR__ . '/database',
__DIR__ . '/routes',
__DIR__ . '/tests',
])
->withPhpSets() // upgrade to your composer.json PHP version
->withPreparedSets(
deadCode: true, // remove unreachable/dead code
codeQuality: true, // simplify and tighten expressions
typeDeclarations: true, // add missing param/return/property types
privatization: true, // make members as private as possible
earlyReturn: true, // flatten nested conditionals
strictBooleans: true, // no truthy/falsy sloppiness
)
->withSets([
LaravelSetList::LARAVEL_CODE_QUALITY,
LaravelSetList::LARAVEL_IF_HELPERS,
LaravelSetList::LARAVEL_ELOQUENT_MAGIC_METHOD_TO_QUERY_BUILDER,
])
->withImportNames(removeUnusedImports: true); The typeDeclarations set is the star: it walks your code and adds the type hints you forgot, which feeds directly into PHPStan finding more. Run it in preview mode first:
./vendor/bin/rector process --dry-run # show diffs, change nothing
./vendor/bin/rector process # applyBecause Rector edits code, always run it against a clean Git working tree and review the diff. On a new project the diff is tiny; the value compounds later, when Rector upgrades you from PHP 8.4 to 8.5 or from Laravel 12 to 13 in a single automated pass.
Pint Configured with declare_strict_types, strict_comparison, final_class
Laravel Pint ships with every new Laravel app — it's an opinionated, zero-config code formatter built on PHP-CS-Fixer. Out of the box it enforces the laravel preset. But Pint is also the enforcement layer for most of the rules in this article. Drop a pint.json at the root:
{
"preset": "laravel",
"rules": {
"declare_strict_types": true,
"strict_comparison": true,
"strict_param": true,
"final_class": true,
"final_internal_class": true,
"fully_qualified_strict_types": true,
"global_namespace_import": {
"import_classes": true,
"import_constants": true,
"import_functions": true
},
"no_unused_imports": true,
"ordered_imports": { "sort_algorithm": "alpha" },
"ordered_class_elements": true
}
}What each key rule buys you:
declare_strict_types— automatically insertsdeclare(strict_types=1);into every file. This is how you make "strict types everywhere" a formatting concern instead of a code-review nag.strict_comparison— rewrites==to===and!=to!==. No more0 == "hello"surprises from loose comparison.strict_param— forces the strict flag on functions likein_array()andarray_search(), closing the same type-juggling hole.final_class— marks classesfinalautomatically, implementing the "final by default" principle without manual effort.
./vendor/bin/pint # fix everything
./vendor/bin/pint --test # CI mode: fail if anything isn't formatted Run pint --test in CI and pint in a pre-commit hook. Now strict types, strict comparisons, and final classes are enforced mechanically on every commit — nobody has to remember them, and nobody can forget.
Actions Instead of Fat Controllers
The default Laravel tutorial teaches you to put logic in controllers. It works, right up until a controller has fifteen methods, each 60 lines long, sharing tangled private helpers. Business logic buried in controllers is hard to test (you have to simulate an HTTP request), impossible to reuse (you can't call a controller method from a queue job or a command), and a magnet for merge conflicts.
The Action pattern extracts each unit of business logic into its own single-purpose, invokable class:
<?php
declare(strict_types=1);
namespace App\Actions;
use App\Data\CreateOrderData;
use App\Models\Order;
final readonly class CreateOrder
{
public function __construct(
private ChargePayment $chargePayment,
private DispatchOrderConfirmation $confirmation,
) {}
public function handle(CreateOrderData $data): Order
{
$order = Order::create([
'user_id' => $data->userId,
'total' => $data->total,
]);
$this->chargePayment->handle($order);
$this->confirmation->handle($order);
return $order;
}
}The controller shrinks to plumbing — validate input, call the action, return a response:
final class OrderController
{
public function store(StoreOrderRequest $request, CreateOrder $createOrder): RedirectResponse
{
$order = $createOrder->handle(
CreateOrderData::from($request->validated())
);
return to_route('orders.show', $order);
}
}The payoff:
- Testable in isolation — unit-test
CreateOrderdirectly, no HTTP layer required. - Reusable everywhere — the same action runs from a controller, an Artisan command, a queued job, or another action.
- Composable — actions inject and call other actions, building complex flows from small, named, single-responsibility pieces.
- Readable diffs — one behavior lives in one file, so changes are localized and conflicts are rare.
One action = one verb = one file. When you can name a class after exactly what it does (CreateOrder, ChargePayment, CancelSubscription), your codebase becomes a readable index of what the application actually does.
CarbonImmutable: Dates That Can't Change Under You
Laravel casts dates to Carbon instances, which are mutable. That leads to one of the sneakiest bugs in PHP:
$startOfMonth = $order->created_at;
$endOfMonth = $startOfMonth->endOfMonth(); // 😱 mutates $startOfMonth too!
// Now $startOfMonth is ALSO the end of the month.
// Both variables point to the same mutated object.CarbonImmutable fixes this: every operation returns a new instance and leaves the original untouched, exactly like PHP's own DateTimeImmutable. Tell Laravel to use it globally in AppServiceProvider::boot():
use Illuminate\Support\Carbon;
public function boot(): void
{
Carbon::useImmutable(); // Laravel 11+
} On older versions, or to be explicit, set the model date cast to immutable_datetime:
protected function casts(): array
{
return [
'published_at' => 'immutable_datetime',
'created_at' => 'immutable_datetime',
];
} With immutable dates, the earlier bug becomes correct by construction — $startOfMonth stays the start of the month no matter what you compute from it. It's the same immutability principle as readonly, applied to the datatype your business logic touches most.
The Complete AppServiceProvider — Copy, Paste, Adjust
Here's everything that belongs in one place. This boot() method is the runtime half of the setup (the rest — Pint, PHPStan, Rector — lives in config files and CI):
<?php
declare(strict_types=1);
namespace App\Providers;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\ServiceProvider;
final class AppServiceProvider extends ServiceProvider
{
public function boot(): void
{
// Immutable dates everywhere.
Carbon::useImmutable();
// Trade fillable/guarded for typed DTOs (deliberate choice).
Model::unguard();
// Fail loudly in non-production: N+1s, missing & discarded attributes.
Model::shouldBeStrict(! $this->app->isProduction());
// Block destructive DB commands (db:wipe, migrate:fresh) in production.
DB::prohibitDestructiveCommands($this->app->isProduction());
// Optional: force HTTPS in production, disable in local.
// URL::forceScheme('https');
}
} The one bonus line worth calling out: DB::prohibitDestructiveCommands(). It stops anyone (or any misconfigured deploy script) from running migrate:fresh or db:wipe against production — a footgun that has erased more than one company's data. Enable it wherever isProduction() is true.
Add a couple of Composer scripts so the whole team runs the same checks:
"scripts": {
"lint": "pint",
"test:lint": "pint --test",
"test:types": "phpstan analyse",
"test:refactor": "rector process --dry-run",
"refactor": "rector process",
"test": [
"@test:lint",
"@test:refactor",
"@test:types",
"@php artisan test"
]
} Now composer test runs formatting checks, a Rector dry-run, static analysis at max level, and your test suite — the exact same gate that runs in CI. If it's green locally, it's green on the pipeline.
Frequently Asked Questions
Won't all this strictness slow down early development?
The opposite, once it's set up. The one-time cost is an hour of configuration. After that, PHPStan and shouldBeStrict() catch bugs at the moment you write them — in your editor and terminal — instead of days later in production, where they cost 100× more to diagnose. Strict setups feel slower for the first week and are dramatically faster from then on.
Should I add this to an existing project or only new ones?
Both, but differently. New projects start at PHPStan level max with zero baseline — the ideal case. Existing projects should generate a PHPStan baseline (--generate-baseline), ratchet the level up one step at a time, and introduce shouldBeStrict() in staging first to surface existing N+1s before enabling it broadly.
Is Model::unguard() actually safe?
Only if you never pass raw request data into a model. Validate into Form Requests, map into typed DTOs, and hand Eloquent explicit fields. If your team follows that discipline, unguard() is safe and removes boilerplate. If not, keep $fillable. It's a team-maturity decision, not a universal rule.
final classes break my mocks in tests. Now what?
Prefer testing against interfaces and injecting real or fake implementations rather than mocking concrete classes. Where you genuinely need to mock a concrete class, leave that specific class non-final. Modern PHP test tooling (Pest, Mockery with certain configs) can also handle finals in some cases. Keep final as the default and open classes deliberately.
Do I need both Rector and Pint — don't they overlap?
They're complementary. Pint handles formatting and style-level rules (spacing, import order, declare_strict_types, final_class). Rector performs deeper semantic refactors (adding type declarations, upgrading syntax across PHP versions, removing dead code, flattening conditionals). Run Rector first, then Pint to tidy the formatting of whatever Rector changed.
What PHP and Laravel versions does this assume?
PHP 8.2+ (for readonly class) and Laravel 11+ (for Carbon::useImmutable() and DB::prohibitDestructiveCommands()). Most of the philosophy applies to earlier versions with minor syntax adjustments — readonly properties work in 8.1, and immutable dates can be set via casts on any recent Laravel.
Conclusion: Strict by Default Is a Gift to Future You
None of the practices in this guide are exotic. They're one line in a service provider, a handful of config files, and a naming convention. What they share is a single principle that has spread across the modern Laravel community: make the framework and the tooling enforce your standards, so humans don't have to remember them.
declare(strict_types=1) ends type coercion bugs. shouldBeStrict() turns N+1s and typos into hard errors. final and readonly make illegal states unrepresentable. PHPStan at max level, Rector, and Pint move failures from production to your pipeline. Actions keep logic testable and reusable. CarbonImmutable ends a whole family of date bugs.
Your setup checklist for the next new project:
pint.json with declare_strict_types, strict_comparison, final_classphpstan.neon to level: max, wire it into CIrector.php with dead-code, type-declaration, and Laravel setsshouldBeStrict(), useImmutable(), and prohibitDestructiveCommands() in AppServiceProvider::boot()composer test script that runs Pint, Rector, PHPStan, and your tests togetherSpend the first hour once. Every hour after that, the tooling pays you back. That's the whole trade — and on a brand-new project, with no legacy debt to fight, it's the cheapest it will ever be. Set the standard now, and let the machine hold the line.


