DEV Community

Cover image for PHP: Stop Passing Arrays Everywhere
Lars Moelleken
Lars Moelleken

Posted on

PHP: Stop Passing Arrays Everywhere

Designing Explicit Data Contracts

#blogPostAsWebApp: https://voku.github.io/PHPArrayBox/

Rule of thumb:
If your array-shape PHPDoc needs line breaks, comments, or nested generics — congratulations, you’ve just designed an object. You just didn’t have the courage to admit it.


Introduction: The $data Anti-Pattern Nobody Wants to Own

Every PHP codebase has it.

function handle(array $data): void
{
    // 🤞 good luck
}
Enter fullscreen mode Exit fullscreen mode

It starts harmlessly. A quick prototype. A deadline. “We’ll refactor it later.”

Six months later, $data is a structural landfill:

  • undocumented keys
  • optional flags with magic meaning
  • half the validation duplicated across the codebase
  • and a PHPStan array-shape that looks like a legal contract

If your system relies on arrays to model domain concepts, you don’t have flexibility — you have structural debt.


Background: Why PHP Developers Fell in Love with Arrays

Let’s be fair. PHP trained us this way.

Historically:

  • No enums
  • Weak typing
  • No readonly properties
  • Poor static analysis

Frameworks pushing “just pass arrays”

Arrays were:

  • easy
  • fast to write
  • impossible to reason about long-term

That excuse died at least with PHP 8.x.

Today we have:

  • readonly value objects
  • enums
  • promoted constructors
  • strict typing
  • PHPStan that actually enforces contracts

Continuing to model domain data as arrays in 2026 isn’t pragmatic — it’s lazy.


Core Argument: Arrays Are Not Data Contracts

An array is a container.
A data contract is a promise.

Arrays:

  • allow invalid states
  • encode meaning implicitly
  • rely on comments and discipline

Objects:

  • enforce invariants
  • encode intent
  • fail loudly and early

If a function expects specific data, then that data deserves a name, a type, and rules.


The Red Flag: When Array Shapes Go Wild 🚨

This is where things usually go off the rails:

/**
 * @param array{
 *   id: int,
 *   email: non-empty-string,
 *   status: 'active'|'inactive',
 *   profile?: array{
 *     firstName: string,
 *     lastName: string,
 *     age?: int<0, 120>
 *   },
 *   meta?: array<string, scalar>
 * } $user
 */
function processUser(array $user): void
{
}
Enter fullscreen mode Exit fullscreen mode

Let’s be honest:

  • This is not flexible
  • This is not readable
  • This is not reusable

This is an object that is cosplaying as an array.

Hard Rule (Write This on a Sticky Note)

If your array-shape PHPDoc needs more than ~3 keys, you should stop and create an object.

Array-shapes are a transitional tool, not a destination.


The Correct Alternative: Explicit Data Contracts

Step 1: Name the Concept

If the data has meaning, give it a name.

final readonly class UserProfile
{
    public function __construct(
        public string $firstName,
        public string $lastName,
        public ?int $age,
    ) {}
}

enum UserStatus: string
{
    case Active = 'active';
    case Inactive = 'inactive';
}

final readonly class User
{
    public function __construct(
        public int $id,
        public string $email,
        public UserStatus $status,
        public ?UserProfile $profile,
        public array $meta = [],
    ) {}
}
Enter fullscreen mode Exit fullscreen mode

Now compare usage:

function processUser(User $user): void
{
    if ($user->status === UserStatus::Inactive) {
        return;
    }

    // Autocomplete, refactor-safe, readable
}
Enter fullscreen mode Exit fullscreen mode

No guessing. No comments. No defensive isset() soup.


Static Analysis: From Afterthought to Design Tool

PHPStan isn’t just a linter — it’s a design feedback loop.

With explicit objects:

  • invalid states become unrepresentable
  • missing fields fail at construction time
  • refactors are mechanical, not archaeological

This is the difference between:

  • hoping your code is correct
  • and knowing it can’t be wrong

If PHPStan complains early, your production incidents complain less.


Real-World Refactoring Strategy (Without Burning the Team)

“No greenfield refactors” is a fair rule. Here’s how you do this safely:

  1. Keep Arrays at the Boundaries
  2. HTTP requests
  3. JSON decoding
  4. database rows

  5. Convert Once

$user = User::fromArray($requestData);
Enter fullscreen mode Exit fullscreen mode
  1. Never Pass Raw Arrays Deeper

After the boundary, objects only.

This creates an anti-corruption layer:

  • legacy stays contained
  • new code stays clean
  • refactoring becomes incremental

Common Pushback (And Why It’s Wrong)

“This is too verbose”

Correct.
So are seatbelts.

“Arrays are more flexible”

They are less explicit, not more flexible.

“This slows us down”

Only until the second bug you don’t have to debug.

“We trust our developers”

Then give them tools that enforce correctness instead of relying on memory.


When Arrays Are Actually Fine (Yes, Really)

Arrays still have a place:

  • serialization formats
  • infrastructure boundaries
  • simple lists (list)
  • performance-critical internal transformations (measured!)

Rule:

Arrays describe structure.
Objects describe meaning.

Mixing the two is how systems rot.


Best Practices Summary

❌ array $data is not an API

❌ Massive array-shapes are a code smell

✅ Name your data

✅ Use readonly value objects

✅ Enums over strings

✅ Convert arrays once, at the boundary

✅ Let PHPStan enforce contracts


Conclusion: Arrays Are a Smell, Not a Strategy

If your codebase relies on:

  • comments to explain data
  • array keys to encode rules
  • discipline instead of constraints

You’re not designing software — you’re managing risk manually.

Modern PHP gives us the tools to do better.
Using them is not “overengineering”.
It’s professionalism.


What’s Next?

If this resonated, good — it means you’re ready for the next step:

  • Designing immutable domain models
  • Enforcing invariants with constructors
  • Using PHPStan as an architectural guardrail
  • Killing $data once and for all

Stop passing arrays. Start designing systems.

You’ll never want to go back.

Top comments (13)

Collapse
 
xwero profile image
david duymelinck

Let PHPStan enforce contracts

I let PHP enforce contracts. The problem I have with PHPStan to enforce contracts is that bugs can still occur when running the code in production.
I don't want to let PHPStan be the Typescript of PHP.

Collapse
 
suckup_de profile image
Lars Moelleken
  • Runtime errors means errors for users
  • High phpstan level shape your architecture for good (+ in the end also good autocompletion in the IDE)
  • nobody stops you to force things with php and still use phpstan (+ often phpstan force you to use types and interfaces etc. from php)
  • in the end nothing but real thinking prevents logic bugs 😊
Collapse
 
xwero profile image
david duymelinck

To be clear, I'm not against PHPStan.
It is the array<string, scalar> and non-empty-string comments that gives me the creeps.
In the end PHPStan is an additional tool. It shouldn't be considered as part of the language.

Thread Thread
 
suckup_de profile image
Lars Moelleken

Why not, even the language developers from php itself though of this more than once, same as hack (Facebook) who has introduced Generics since many years this way. docs.hhvm.com/hack/reified-generic...

Thread Thread
 
xwero profile image
david duymelinck

I think you misunderstood the last sentence of my comment.
It is not that I don't want generics in PHP, I don't want generics to handled only by PHPStan.

Thread Thread
 
suckup_de profile image
Lars Moelleken

I understand, that's why I used that example..., what if the php foundation would include it into php, same as hack did it? (Hack supports generics for type safety, but by default they are erased at runtime (type erasure).)

Thread Thread
 
xwero profile image
david duymelinck • Edited

Generics would be nice, but there are other ways to get the same result. For example an object that only accepts objects that have string and scalar properties.
It is more verbose than generics, but it has the same reliability.

Thread Thread
 
suckup_de profile image
Lars Moelleken

Yes, for many use-cases adding more code is often a more readable way instead of using generics but often enough at some important core points a generic is very helpful to maintaining reliability and to increase developer experience, see : phpstan.org/blog/generics-by-examples

Thread Thread
 
xwero profile image
david duymelinck • Edited

Sure there are cases where generics have added reliability over the current PHP functionality, like matching the input type with the output type.
At the moment the only thing we can do to be sure of that is by adding tests. But we are doing that anyway, so that is another way to mitigate the lack of generics.

I think the question is more what are the benefits of adding generics at this point?
Wouldn't it be easier to implement the generics functionality that is not covered by the current code practices as standalone features?
While I don't like PHPStan for generics, I seem to be an outsider voice. For example the new Symfony configuration threw the builder pattern overboard in favor of an array and one of the reasons they gave is IDE and PHPStan understanding of array shapes.
I don't think they would implemented it that way if they sensed there was no community support for it.

I understand generics are a well known concept, and it could make the transition from another programming language easier. But not every language with generics has the same functionality so then the PHP maintainers have to deal with people that are not satisfied with the way they added generics.

Thread Thread
 
suckup_de profile image
Lars Moelleken • Edited

As you already mentioned, phpstan (Generics and Co.) are only additional functions; PHP enforces reality. Static analysis enforces possibilities.

Two different ways to avoid "simple" errors, why shouldn't we use both?

PS: I am also like...

  • the DX (Developer Experience) part of it, like the instant feedback loop in the IDE (without running any test): if you pass 64 but only int<1,32> is allowed
  • the security aspect if your sql string is blocked because it contains non literal-string
  • the documentation part, so that we now can use syntax to describe what a yield will return Generator<int,User> and as extra we receive autocompletion in the IDE

... So why not?

Thread Thread
 
xwero profile image
david duymelinck

Sure if you want to use both that is fine. And your reasons are valid.
It is only using PHPStan when in can be in code that rubs me the wrong way.

And in the case of the Symfony configuration, I'm on board with their reasoning.
I can adjust my point of view on a case to case basis.

I think we have a similar perspective, it was the quote in my first comment that made me think we were further apart in opinions.

Collapse
 
spo0q profile image
spO0q • Edited

Solid post!

Indeed, these collections of mixed types are convenient but prone to various bugs.

PHPStan allows catching errors early, but it can be hard to use for legacy code. This is not uncommon teams set a lower level of constraint for old code that will be refactored later.

It becomes even more tedious with frameworks and helpers from third-party tools.

Collapse
 
suckup_de profile image
Lars Moelleken

You can generate a phpstan baseline file, where you ignore all old errros, and just start to write maintainable code.

For frameworks and other tools there are many phpstan extensions that supports you e.g. auto detect types directory from your sql queries or other magic stuff, if you really need it.