Laravel · Php

PHP Strict Types — A Field Guide

What declare(strict_types=1) actually changes in PHP: the difference between coercive and strict argument checking, why it is a per-file declaration, and how to adopt it in a legacy codebase without a big-bang rewrite.

John Kihiu12 min read

PHP's type system is opt-in, and most of the confusion I see around declare(strict_types=1) comes from not knowing exactly what it opts you into. It is not a global "strict mode" for the interpreter, it does not affect arithmetic or string handling, and it does not even change how the function you are calling behaves. It changes exactly one thing: how PHP checks scalar type declarations on function and method calls made from the file where it is declared.

Coercive mode is the default, and it is more permissive than people assume

Without strict_types, PHP runs in coercive mode. If a function signature says a parameter is int and you pass the string "5", PHP converts it. Pass 5.7 to an int parameter and PHP truncates it to 5 — silently, no warning. Pass "5 apples" and you get a deprecation notice in PHP 8.1+ but the call still happens, coerced to 5. This is convenient for a lot of ad hoc scripting, and it is exactly the behavior that turns a typo or a bad form input into a silent data bug three layers deep in a codebase that never validates at the boundary.

PHP · COERCIVE VS STRICT
<?php
// coercive.php — no declare(strict_types=1)

function applyDiscount(int $percent, float $price): float
{
    return $price * (1 - $percent / 100);
}

echo applyDiscount("10", "49.99");   // works: "10" -> 10, "49.99" -> 49.99
echo applyDiscount(true, "49.99");   // works: true -> 1
echo applyDiscount("10%", 49.99);    // TypeError even in coercive mode:
                                      // "10%" is not a well-formed numeric string
PHP · STRICT MODE
<?php
declare(strict_types=1);
// strict.php

function applyDiscount(int $percent, float $price): float
{
    return $price * (1 - $percent / 100);
}

echo applyDiscount(10, 49.99);    // fine: exact types
echo applyDiscount(10, 49);       // fine: int -> float is the ONE allowed widening
echo applyDiscount("10", 49.99);  // TypeError: strict mode does not coerce
                                    // string "10" to int, even though it looks safe

That last line is the detail people trip over: strict mode allows exactly one coercion, int to float, because that widening never loses information. Every other combination — string to int, int to string, bool to anything, float to int — is rejected with a TypeError. There's no partial strictness; it's on or off per call site.

The declaration is per-file, and it governs the caller, not the callee

This is the part of the documentation that reads clearly but still surprises people the first time they see it in practice: strict_types is not a property of the function being called. It is a property of the file containing the call. If library.php defines applyDiscount() with no declare statement, and you call it from app.php which does have declare(strict_types=1), the call is checked strictly — because the check happens based on where the call is written, not where the function lives. Flip it around and it's the same story: a strict-mode function definition called from a non-strict file is checked coercively, because the caller's file controls the rule.

Practical consequence

Adding declare(strict_types=1) to a library file you maintain does not force strict checking on everyone who calls into it — it only tightens the calls that library file itself makes to other functions. If you want strict guarantees for callers of your public API, the guarantee has to come from validating input yourself, not from the declaration in your file.

Why it has to be the very first statement in the file

declare(strict_types=1) is a compile-time directive, not a runtime one — PHP needs to know the mode before it parses any executable code in the file so it can generate the right bytecode for every call site. That's why it must appear before any other statement, including comments-adjacent whitespace tricks people try, and why it can't be conditional (if ($env === 'strict') declare(...) is a fatal error, not a no-op). It's also why there is no equivalent "declare strict for the whole project" flag — each file opts in individually, deliberately, because the language designers wanted the choice to be visible at the top of every file rather than buried in a config setting someone forgets exists.

Adopting it in a legacy codebase without breaking everything at once

The mistake I've seen teams make is trying to add strict_types to an entire codebase in one pull request. Because the check applies per calling file, you can actually do this incrementally and safely: add the declaration to one file, run your test suite, see what breaks. What breaks is almost always a call site passing a loosely-typed value — a string from a query parameter, a form field, an array key — into a function expecting a specific scalar type. Fixing that call site (cast explicitly, validate at the boundary) is usually a net improvement to the code regardless of strict mode.

Start at the edges, not the core

Add strict_types first to files that are pure logic — services, calculators, anything without direct $_GET/$_POST/database-row handling. Those are the safest to convert and the most likely to have unnoticed coercion bugs. Leave the messiest input-handling files for last, once you've built confidence in the pattern.

One more nuance worth knowing: strict_types only affects scalar type declarations (int, float, string, bool) on function arguments and return types. It has no effect on array types, class/interface type hints, or nullable types — those are always checked the same way regardless of the declaration. It also doesn't touch arithmetic operators, string interpolation, or comparisons; "5" == 5 is still true in both modes, because that's a completely different part of the type juggling rules, unrelated to strict_types.

Wrapping up

declare(strict_types=1) is a narrow, well-scoped tool: it only changes how scalar type declarations are enforced on calls made from the file that declares it, it allows exactly one coercion (int to float), and it says nothing about how the function you're calling was written. Add it file by file, starting with your pure logic, and use the type errors it surfaces as a map of where implicit coercion was quietly doing work you didn't know about.

John Kihiu
Acumatica ERP Developer · Laravel Engineer

Independent software engineer in Nairobi specialising in Acumatica customisations, Laravel backends, and tax fiscalisation integrations across East and Southern Africa.