Why strict types matter
Silent type coercion has cost teams hours of debugging. A function that expects an integer silently accepts a string, your logic branches incorrectly, and the bug surfaces in production three months later. Strict types in PHP give us a guardrail: if you pass the wrong type, PHP throws an error immediately, not in some downstream condition. This is the difference between catching a typo in your test suite and discovering it affects your payment processing.
We're writing about strict types in PHP: a PHP & Laravel guide for developers because this is one of the highest-leverage practices for mid-level and senior teams. It forces discipline early and integrates naturally with modern Laravel patterns. Combined with static analysis tools, strict types become your first line of defense.
declare(strict_types=1) at the top of each file
Every PHP file that you want type-safe must begin with the declaration. This is not a global setting—it's per-file and only affects code within that file.
<?php
declare(strict_types=1);
namespace App\Services;The moment you add this line, PHP stops auto-coercing types. Pass a string where an int is expected, and you get a TypeError. This strictness applies to function arguments, return types, and property types.
In a real-world Laravel application, we enforce this across the codebase. A common pattern:
- All service classes use strict types.
- All controllers use strict types.
- All models use strict types.
- Test files use strict types.
We add a pre-commit hook or CI check to verify every new file includes this declaration. Over time, your team's reflex becomes automatic: open a new file, type the declaration, move forward.
Type every parameter and return
Strict types only work if you actually declare types. A function that takes any input and returns any output gives you nothing.
// Without types—strict_types=1 has no effect
function processOrder($order, $userId) {
return $order->total() * 1.1;
}With strict types and explicit signatures:
// With types—now strict_types=1 protects us
function processOrder(Order $order, int $userId): float {
return $order->total() * 1.1;
}Now if someone calls processOrder($order, "user-123"), PHP throws a TypeError immediately. The string is not coerced to int; the error is caught where it happens.
In Laravel applications, this extends to controller actions:
public function store(StoreUserRequest $request, UserService $service): JsonResponse {
$user = $service->create(
$request->validated()
);
return response()->json([
'id' => $user->id,
'email' => $user->email,
], 201);
}Every parameter and every return value has a type. The request is validated, the service returns a model, and the response is typed as JsonResponse. Anyone reading this code knows exactly what flows in and what flows out.
For model methods and repository classes, the same discipline applies:
class UserRepository {
public function findById(int $id): ?User {
return User::find($id);
}
public function findByEmail(string $email): ?User {
return User::where('email', $email)->first();
}
public function getAllActive(): \Illuminate\Database\Eloquent\Collection {
return User::where('is_active', true)->get();
}
}The method names are clear, but the types make the contract explicit. A developer using this repository knows that findById expects an int and may return null. There's no guessing or reading the implementation.
Union and nullable types
PHP 8+ gives us union types and better nullable syntax. These let us express more complex but still strict contracts.
Before PHP 8, nullable types used ?Type:
public function getUser(int $id): ?User {
return User::find($id);
}Union types let a function accept or return one of several specific types:
public function sendNotification(int|string $userId, User|string $email): bool {
// Handle either an int or string user ID
// Handle either a User object or string email
}Union types are powerful but should be used deliberately. If you find yourself using int|string|float|bool, you likely have a design problem. Use unions when the variation is meaningful:
// Good: Status can be a string or a Status enum
public function setStatus(string|Status $status): void {
$this->status = $status instanceof Status ? $status : Status::from($status);
}
// Avoid: This is too broad
public function process(mixed $input): mixed {
// What are you actually processing?
}In Laravel, this pattern is clean for handling polymorphic relationships or flexible input:
// Event listener accepting different event types
public function handle(UserCreated|UserUpdated $event): void {
$user = $event->user;
Log::info('User event', ['user_id' => $user->id]);
}Use PHPStan to check the whole codebase
Strict types protect against runtime errors in the code you execute. PHPStan is a static analysis tool that checks your code without running it, catching logical bugs and type mismatches that strict types alone miss.
Install it:
composer require --dev phpstan/phpstanCreate a phpstan.neon config file at your project root:
includes:
- vendor/phpstan/phpstan-strict-rules/rules.neon
parameters:
level: 8
paths:
- app
- tests
ignoreErrors:
- message: '#Call to an undefined method#'
paths:
- app/Models/*
- message: '#Unsafe access to offset#'Run the analysis:
./vendor/bin/phpstan analysePHPStan will report issues like:
- Accessing a property that might not exist.
- Calling a method that doesn't match the declared return type.
- Passing an argument that doesn't match the parameter type, even if strict types aren't enabled in that file.
- Dead code or unreachable branches.
A practical example: you refactor a service method to return Collection instead of array, but forget to update a controller that calls count() on the result. Strict types at runtime won't catch this—both are countable. PHPStan will, because it knows that Collection and array are different types and your code assumes array in some places.
Integrate PHPStan into CI:
# .github/workflows/ci.yml
- name: PHPStan
run: ./vendor/bin/phpstan analyseEvery pull request is checked. Over time, your codebase becomes more type-safe, more predictable, and easier to refactor.
Common mistakes
Forgetting strict_types=1 in a file. You add types to a function, but another file calls it with the wrong type. That file doesn't have strict types enabled, so PHP silently coerces the value. Use a linter to enforce the declaration on every file.
Mixing strict and non-strict files. If your codebase is half strict and half loose, the loose parts become blind spots. Enforce strict types as a team standard, even if it means updating legacy files.
Over-using mixed. If a parameter is mixed, you've essentially disabled type safety for that input. Use it only as a temporary placeholder while refactoring, then replace it with a union or interface.
Ignoring PHPStan warnings. Suppressing errors with comments like // @phpstan-ignore-next-line is a code smell. If PHPStan complains, there's usually a real issue. Investigate and fix the root cause.
Using too broad a type. Making everything accept or return a base class or interface sounds flexible but loses safety. If a method truly only works with User, not Model, say so.
Key takeaways
- Add
declare(strict_types=1);at the top of every PHP file you write. Make it automatic. - Type every function parameter and return value. Consistency is more important than perfect types.
- Use union types and nullable types deliberately, not as an escape hatch for unclear logic.
- Run PHPStan in CI on every pull request. Set the level to 8 or higher for maximum safety.
- Treat type errors as bugs, not warnings. A
TypeErroris your code telling you something is wrong before it breaks production. - When refactoring, lean on types to guide changes. The compiler is your co-pilot.






Comments
Loading comments…