One line, every file

<?php

declare(strict_types=1);

namespace App\Services;

That's the whole post, really. The rest is why.

What it actually does

By default, PHP coerces scalar types at function boundaries. If a method is typed int $userId and you call it with '42', PHP shrugs and converts the string. Same for '42abc' — except now you've silently lost data, because (int)'42abc' is 42 with no warning.

With declare(strict_types=1), PHP stops coercing. Pass a string to an int parameter and you get a TypeError at the call site. Pass null to a non-nullable parameter and you get a TypeError. The function's signature becomes a contract instead of a suggestion.

The declaration applies to the file it's in, not the file it's calling. That's the key thing to understand — your strict file calling someone else's loose file still enforces your types on the way in.

Why Laravel makes this more important, not less

Laravel leans on dynamic-ish patterns. Magic methods, facade proxies, model attribute access, request input that's always either a string or null depending on whether someone filled in the form. None of these care about your types until something explodes at runtime, usually in a queue worker, on a Sunday.

A typical example:

public function process(int $orderId): void
{
    $order = Order::findOrFail($orderId);
    // ...
}

// In a controller:
$this->processor->process($request->input('order_id'));

$request->input('order_id') returns a string. Without strict types, PHP coerces it and the method runs. With strict types, you get a TypeError the first time you run the test — before the bug ships. The fix is one line:

$this->processor->process($request->integer('order_id'));

Now the controller is explicit about what it's doing, and the service signature isn't lying anymore. Multiply this by every controller, every job, every event listener, and you stop a whole category of "works in dev, breaks in prod" bugs.

The three gotchas

In several years of running this on every project, three things have bitten me. They're all easy to handle once you know.

1. Carbon dates and int|string IDs. Older Laravel code sometimes types model primary keys loosely, and packages occasionally return int|string because UUIDs and auto-increments coexist. If you're calling third-party code, your strict file still has to satisfy its signature. Cast at the boundary:

$user = User::find((int) $payload['user_id']);

Annoying for thirty seconds, then you forget it exists.

2. Float-to-int gotchas with divide and timestamps. PHP's division returns float, even when both operands are integers. Under strict types, you can't pass that float to an int parameter without an explicit cast. This is correct behaviour but it surprises people:

$page = $total / $perPage;        // float, even if both are int
$this->goToPage((int) $page);     // explicit cast required

3. Tests that build models with array fixtures. Factories handle this fine, but hand-built fixtures sometimes pass '1' where the model expects an int. Strict types in your test file doesn't change how the model behaves — but if your test calls a service with a strict signature, that fixture string will trip the type check. The fix is usually that the fixture was wrong all along; strict types just surfaced it.

None of these are reasons not to turn it on. They're reasons to know what you're signing up for.

How to roll it out

On a new project, set it as a Pint rule and never think about it again:

{
    "rules": {
        "declare_strict_types": true
    }
}

./vendor/bin/pint will add the declaration to every file that doesn't have it. Commit, done.

On an existing project, don't try to enable it everywhere in one PR. You'll spend a week chasing type errors in code you weren't planning to touch. Instead:

  1. Add the Pint rule but don't run it across the whole repo yet.
  2. New files get strict types automatically because Pint runs on commit.
  3. When you touch an existing file for any reason, add the declaration as part of that change and fix the fallout in that file's scope.
  4. Six months later you'll look up and most of the codebase is converted.

That's it. One line per file, paid back in TypeError tracebacks that point at the actual call site instead of three layers deeper where the coerced value finally caused something visible.

If you're already running Pint and PHPStan, you've done most of the cultural work. The declaration is just the runtime enforcement of what your static analyser is already telling you. Belt and braces, and the braces cost nothing.