I spent twenty minutes last year staring at a service class with declare(strict_types=1) on line 3 that was happily accepting a string where its signature said int. The declaration was there. The type was there. PHP coerced anyway.
The declaration was in the wrong file.
What the declaration actually governs#
declare(strict_types=1) switches scalar type checks from coercive mode to strict mode for calls made from the file containing the declaration. It is a compile-time directive — the compiler sets a flag on the call opcodes in that file and nothing else.
That is the whole feature. Not a project setting, not a runtime ini value, not an attribute of the class you put it on.
The split matters: argument checks follow the caller, because passing the wrong type is the caller's fault. Return checks follow the file where the function is defined, because returning the wrong type is the callee's fault. Typed property assignment follows the file where the assignment happens.
Every one of those is per-file.
The caller-side rule, demonstrated#
Three files. One function.
<?php
// app/Support/Money.php — NO declaration
namespace App\Support;
final class Money
{
public static function pence(int $amount): int
{
return $amount;
}
}
<?php
// app/Http/Controllers/LooseController.php — NO declaration
use App\Support\Money;
Money::pence("7"); // => int(7). Coerced, silently.
<?php
declare(strict_types=1);
// app/Http/Controllers/StrictController.php
use App\Support\Money;
Money::pence("7"); // => TypeError: Argument #1 ($amount) must be of type int, string given
Same method, same signature, two behaviours. The file that declares the function has no say in it.
This is the bit that makes people conclude strict types "doesn't work". They armour the service layer and leave the controllers, jobs and commands that call into it undeclared, which is precisely backwards.
What coercive mode does silently#
Weak mode is not uniformly harmless. Against an int parameter:
| You pass | You get | Lossy? |
|---|---|---|
"7" |
7 |
No |
"7.0" |
7 |
No |
7.0 |
7 |
No |
7.9 |
7 + deprecation |
Yes |
"7abc" |
7 + E_WARNING |
Yes |
true |
1 |
No |
"abc" |
TypeError |
n/a |
null |
TypeError (userland) |
n/a |
The row that matters is 7.9. PHP 8.1 added a deprecation notice for implicit float-to-int conversions that lose precision, which helps — if you read your deprecation log. If you don't, a quantity calculated as 9.99 becomes 9, and nobody finds out until someone reconciles a stock count.
The "7" → 7 row, the one everyone cites as the reason to turn strict types on, is the row that has never hurt anyone.
What strict types does not change#
Most of PHP's type juggling is nowhere near a function boundary, so the declaration leaves it alone:
<?php
declare(strict_types=1);
var_dump("10" == "1e1"); // true — comparison, not a function call
var_dump("5" + 5); // int(10) — arithmetic juggling
var_dump(1 + 1.5); // float(2.5) — numeric widening
$a = [];
$a["1"] = 'x';
var_dump(array_keys($a)); // [0 => int(1)] — array key coercion
echo "Total: " . 42; // "Total: 42" — concatenation
And one exception inside the feature itself:
<?php
declare(strict_types=1);
function rate(float $multiplier): float
{
return $multiplier;
}
rate(3); // float(3) — int widens to float even in strict mode. No error.
The int-to-float widening exception is written into the original Scalar Type Declarations RFC and it surprises people every time. If you have (float) casts scattered around purely to feed float parameters, delete them.
One correction to a claim you will see repeated: typed property assignment is not always strict. In a file without the declaration, $order->quantity = "7" on an int property coerces exactly like an argument would. Add the declaration to the assigning file and it throws. Property hooks sit in the same place in that chain — see PHP 8.4 property hooks on Laravel models for where that validation can live instead.
Built-in functions count too#
This is where most first-time strict-types errors come from, and nobody warns you about it. The flag applies to calls into internal functions as well as userland ones:
<?php
declare(strict_types=1);
str_repeat('-', "40"); // TypeError
substr('hello', "1"); // TypeError
array_fill(0, "3", null); // TypeError
number_format(1234.5, "2"); // TypeError
Every one of those is fine without the declaration. Add it to a legacy file and you can light up a dozen errors that have nothing to do with your own code's types — they are the places someone read an integer out of config, a route parameter or a .env value and never cast it.
The reverse case is worth knowing too: code running inside an internal function is always coercive, regardless of your file. ReflectionProperty::setValue() assigns coercively even when your calling file is strict, because the assignment happens in engine code.
The Laravel problem: everything arrives as a string#
HTTP input is strings. Route parameters are strings. env() values are strings. Most of a config/*.php file that reads from env() is strings. So this breaks the moment the controller file gains the declaration:
<?php
declare(strict_types=1);
namespace App\Http\Controllers;
use App\Actions\ReserveStock;
use Illuminate\Http\Request;
final class StockController
{
public function store(Request $request, ReserveStock $reserve)
{
// $request->input('quantity') is string "7" — TypeError
return $reserve->handle($request->input('quantity'));
}
}
There are three honest places to fix it, and they are not equivalent.
Cast in the form request. Cheapest, and it keeps the validation and the cast in one place:
<?php
declare(strict_types=1);
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
final class ReserveStockRequest extends FormRequest
{
public function rules(): array
{
return [
'quantity' => ['required', 'integer', 'min:1'],
];
}
/** Validated input is still strings; cast once, here. */
public function quantity(): int
{
return (int) $this->validated('quantity');
}
}
Cast in a DTO. Better when more than one entry point builds the same shape, and it gives you somewhere to put invariants beyond "is an integer":
<?php
declare(strict_types=1);
namespace App\Data;
use App\Http\Requests\ReserveStockRequest;
use InvalidArgumentException;
final readonly class StockReservation
{
public function __construct(
public int $quantity,
public string $sku,
) {
// The type system can't express this; the constructor can.
if ($this->quantity < 1) {
throw new InvalidArgumentException('Quantity must be positive.');
}
}
public static function fromRequest(ReserveStockRequest $request): self
{
return new self(
quantity: (int) $request->validated('quantity'),
sku: (string) $request->validated('sku'),
);
}
}
That pattern is covered properly in readonly classes and value objects in Laravel. The point for strict types is narrow: you now have exactly one (int) in the codebase for this field, at the boundary, instead of coercion happening invisibly at four different call sites.
Cast at the constructor of the consumer. Fine for a single call site, bad as a habit — you end up with the same cast in every service that touches the field.
Where strict types and PHPStan overlap#
They are complements, and the division is clean:
- PHPStan catches type mismatches it can see — a
stringvariable passed to anintparameter, a return type that doesn't match the inferred value. It catches them in CI, before the code runs, on every path including the ones your tests don't cover. - strict_types catches mismatches at runtime, including the ones static analysis cannot prove: values out of
$request, decoded JSON, database columns a driver hands back as strings.
If you run PHPStan at level 10 on Laravel, you will already be fixing most of what strict types would throw on. That is not a reason to skip the declaration — it is a reason to expect the rollout to be quieter than you feared. The remaining runtime errors cluster exactly where the unknowable data enters.
Adopting it in an existing codebase#
Do not do this in one commit. A repo-wide PR touching 900 files is unreviewable, and every genuine bug it uncovers arrives mixed in with 898 no-op diffs.
Rector has two rules for this. DeclareStrictTypesRector adds the declaration everywhere. SafeDeclareStrictTypesRector adds it only where Rector can see no coercion happening — slower to finish the job, but it never hands you a broken file.
<?php
declare(strict_types=1);
// rector.php — one bounded context at a time
use Rector\Config\RectorConfig;
use Rector\Strict\Rector\FileWithoutNamespace\SafeDeclareStrictTypesRector;
return RectorConfig::configure()
->withPaths([
__DIR__ . '/app/Domain/Billing', // this directory only
])
->withRules([
SafeDeclareStrictTypesRector::class,
]);
Then lock the gate behind you so new files arrive compliant. Pint's declare_strict_types rule does it:
{
"preset": "laravel",
"rules": {
"declare_strict_types": true,
"strict_param": true
}
}
Run it as part of the same command as everything else — Duster, Pint, PHPStan and Rector in one command covers the wiring, and Pint custom rules in a shared config covers sharing it across repos. The broader Rector workflow is in automating Laravel upgrades with Rector.
One PR per directory. Full test suite as the gate. Merge, move on.
Gotchas and Edge Cases#
declare must be the first statement. A blank line before <?php, a UTF-8 BOM, or a stray use above it is a fatal error: strict_types declaration must be the very first statement in the script. Check for BOMs before blaming Rector.
Your test files are callers. A test that passes "1" as an id has worked for years. The day that test file gains the declaration, it fails — and the failure is in the test, not the code under test. Add the declaration to tests/ as its own PR, separately from app/.
int → float is not an error. Covered above, but it is the single most common source of unnecessary defensive casts. Stop writing them.
You cannot fix vendor code, and you don't need to. A package file without the declaration calling into your strict-typed class still coerces. That is correct behaviour, not a leak.
Deliberately coercive legacy code exists. Some old import scripts lean on weak mode on purpose. Leave them. SafeDeclareStrictTypesRector will skip most of them for you.
Wrapping Up#
Put the declaration where the calls originate — controllers, jobs, commands, listeners, tests — not just in the service classes they call into. Cast once at the boundary, in a form request or a DTO, and the rest of the rollout is mechanical: SafeDeclareStrictTypesRector scoped to one directory, a Pint rule to hold the line, one PR per bounded context.
If the rollout surfaces more than you expected, the incremental approach in burning down a PHPStan baseline on legacy Laravel is the same shape of problem and works here too.
FAQ#
What does declare(strict_types=1) actually do in PHP?
It switches scalar type checks from coercive mode to strict mode for function calls made from that file — both userland and built-in functions. Argument checks follow the calling file, return type checks follow the file where the function is defined, and typed property assignment follows the file where the assignment happens. It is a compile-time directive, so it affects only the file it appears in.
Should I use strict_types in a Laravel application?
Yes for new code, and incrementally for existing code. The benefit is that silent lossy coercions — a float truncated to an int, a leading-numeric string accepted as a number — become loud errors at the boundary rather than wrong numbers downstream. The cost is that you have to cast request input explicitly, which is work you should be doing anyway in a form request or a DTO.
Does strict_types apply to the whole file or the whole project?
The whole file, and only that file. There is no project-wide setting, no ini directive and no way to enable it globally. This is the single most common misunderstanding: adding it to a service class does nothing for calls made into that class from a controller that lacks the declaration.
Does strict_types make PHP faster or slower?
Neither, to any degree you can measure in a web application. Strict mode skips the coercion step but still performs a type check; coercive mode does the check and then the conversion. The difference is nanoseconds per call and invisible next to a single database query. Choose the declaration for the error surface it gives you, not for speed.
Why does strict_types break my code when request input is a string?
Because HTTP input, route parameters and env() values all arrive as strings, and under weak mode PHP was quietly converting them for you at every call site. The declaration removes that conversion, so the string reaches your int parameter unchanged and throws a TypeError. The fix is to cast once where the data enters — a form request accessor or a DTO constructor — rather than scattering casts through the call chain.
How do I add strict_types to an existing Laravel codebase safely?
Run Rector's SafeDeclareStrictTypesRector scoped to one directory at a time, with the full test suite as the gate, and merge one PR per bounded context. Do tests/ as its own pass, since test files are callers too and will start failing on fixtures that pass "1" where an int is declared. Then add Pint's declare_strict_types rule so new files arrive with the declaration already in place.