Skip to content
ExponaBLOG
PHP & LARAVEL DEVELOPMENT · DAY 004 / 100

Strict types in PHP: a PHP & Laravel guide for developers

Turn on type checking and catch bugs earlier.

Expona Admin6 min read

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
<?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.

php
// Without types—strict_types=1 has no effect
function processOrder($order, $userId) {
    return $order->total() * 1.1;
}

With strict types and explicit signatures:

php
// 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:

php
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:

php
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:

php
public function getUser(int $id): ?User {
    return User::find($id);
}

Union types let a function accept or return one of several specific types:

php
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:

php
// 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:

php
// 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:

php
composer require --dev phpstan/phpstan

Create a phpstan.neon config file at your project root:

php
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:

php
./vendor/bin/phpstan analyse

PHPStan 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:

php
# .github/workflows/ci.yml
- name: PHPStan
  run: ./vendor/bin/phpstan analyse

Every 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 TypeError is 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.
---
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles