International PHP Conference https://phpconference.com/ IPC 2025 Thu, 03 Sep 2026 08:13:25 +0000 en-US hourly 1 https://wordpress.org/?v=7.1 Invisible Isolation: Multi-Tenancy with PostgreSQL RLS https://phpconference.com/blog/software-architecture/postgresql-row-level-security-multi-tenancy/ Wed, 02 Sep 2026 14:15:50 +0000 https://phpconference.com/?p=210170 This article explores how PostgreSQL's Row-Level Security (RLS) can enforce tenant isolation at the database layer, eliminating one of the most common and dangerous failure points in multi-tenant applications: forgotten tenant filters in application code. Learn how to build a tenant-blind architecture with RLS policies, restricted roles, transaction-scoped tenant context, and practical integration patterns for Laravel and Symfony.

The post Invisible Isolation: Multi-Tenancy with PostgreSQL RLS appeared first on International PHP Conference.

]]>
Every multi-tenant application carries a silent time bomb: a single missing WHERE tenant_id = ? clause. It doesn’t throw an exception, doesn’t trigger a test failure and doesn’t appear in your logs. It simply returns another customer’s data to the wrong user. In a SaaS product with hundreds of tenants, that kind of leak can mean a regulatory incident, a breach notification and the end of customer trust, all triggered by a junior developer who forgot to extend the right base class or apply the right ORM scope on a Friday afternoon.

The conventional defenses (Laravel Global Scopes, Doctrine SQL Filters, custom repository base classes, code review checklists) all share the same fundamental weakness: they depend on developers remembering to opt in. Every new query surface, every raw SQL shortcut, every eager-loaded relation is a potential escape hatch. The enforcement lives in application code, which means it can be forgotten, bypassed or silently broken during a refactor. You’re defending a perimeter made of good intentions.

IPC NEWSLETTER

All news about PHP and web development

[mc4wp-simple-turnstile]

 

PostgreSQL’s Row-Level Security moves that enforcement below the application layer entirely. Policies are attached to the table itself, evaluated by the database engine on every query and physically invisible to the connection executing them. When an application sets a session variable identifying the current tenant, the engine filters every SELECT, guards every INSERT, blocks every UPDATE and rejects every DELETE automatically. The filter is attached to the table itself, not to any application-layer convention that a developer has to remember. The application code becomes genuinely tenant-blind: Order::all() returns only this tenant’s orders, because the database won’t surface any other rows.

This article builds that system from scratch. We’ll harden a PostgreSQL schema with non-bypassable RLS policies, provision a restricted database role that can’t sidestep them, wire a PHP middleware to inject tenant context at the transaction boundary and show concrete integration patterns for both Laravel and Symfony. By the end, the same findAll() call that’s dangerous today becomes provably safe, by construction rather than convention.

Why does every query carry a security risk?

At first glance, manual tenant filtering looks perfectly reasonable. A typical PDO query reads like this:

$stmt = $pdo->prepare('SELECT * FROM orders WHERE tenant_id = ? AND status = ?');
$stmt->execute([$tenantId, $status]);

The intent is clear, the filter is visible and a code reviewer scanning that line will tick it off without concern. The danger lies in every query that follows it, written by someone else, on a different day, under deadline pressure, in a repository that has grown to ninety-odd classes.

Consider this function:

function getRecentOrders(PDO $pdo): array
{
    // Spot the bug: no tenant_id filter
    $stmt = $pdo->query('SELECT * FROM orders WHERE created_at > NOW() - INTERVAL \'7 days\'');
    return $stmt->fetchAll();
}

That function will pass static analysis. It will pass PHP-CS-Fixer. In a development environment with a single test tenant it will produce correct-looking output. Deployed to production, it returns the last seven days of orders for every tenant in the database, to whoever called it first.

This exact shape surfaces in production in four distinct, predictable ways.

A new team member adds a reporting method to OrderRepository. They model it on a similar method in UserRepository that happens not to need tenant scoping. The code review focuses on SQL correctness and the return type. Nobody notices the missing WHERE tenant_id = :tenant. The function works flawlessly in the staging environment, which hosts a single tenant’s data. In production, with four hundred tenants, it’s a cross-tenant data leak on every call.

Raw SQL bypasses ORM scopes. Laravel’s Global Scopes and Doctrine’s SQL Filters are powerful conventions, but they’re conventions applied by the ORM’s query builder. The moment a developer reaches for DB::select(), $pdo->query() or a raw migration script for a bulk update, those scopes are silently absent. Nobody disabled them. They were never consulted. Reports, one-off data corrections and migration scripts are precisely the places where developers reach for raw SQL, and they’re exactly the places where tenant isolation is most likely to be skipped. The ORM abstraction leaks, and tenant isolation leaks through it.

Admin dashboard exposes cross-tenant data. Internal tooling accumulates its own pathologies. An admin panel is built quickly, without the same review rigor as customer-facing code, often with the explicit rationale that “admins see everything, so we don’t need to scope it.” That logic is correct for a superadmin viewing a global report, but the unscoped endpoint now exists in the application. A misconfigured route guard, an overly broad role assignment, a leaked session token or a confused permission check can expose that endpoint to a user who should see only their own tenant’s data. The application now has a loaded gun sitting in a drawer labelled “internal only.”

Queue workers, cron jobs and Artisan or Symfony Console commands execute outside the HTTP request lifecycle. Background jobs have no request context to draw from. Tenant context in those environments is typically derived from the job payload, but ORM scopes that rely on auth()->user()->tenant_id or request()->header(‘X-Tenant-ID’) have no request to read from. Depending on the framework and the scope implementation, the filter either throws an exception at runtime (visible, fixable) or silently defaults to no filter (invisible, catastrophic). The second outcome is more common than the first, because developers test queue workers the same way they test everything else: with a single tenant in a clean database.

What all four failure modes share is that the enforcement mechanism lives in application code written by humans. It can be forgotten on the first pass, removed in a refactor or simply not understood by the next person who touches the file. The only reliable defense is one that doesn’t depend on the developer remembering to opt in.

What if the database made this impossible?

BE ON THE SAFE SIDE!

Explore the PHP Security & Performance Track

 

How does PostgreSQL enforce tenant boundaries at the engine level?

Row-Level Security is a predicate: an invisible WHERE clause attached directly to a table, evaluated by the query planner as though it were part of the original SQL. It isn’t a view, a trigger or a stored procedure wrapper. The application never writes it, the developer never calls it and the engine always applies it. That distinction matters more than it might initially seem.

Enabling it requires a single DDL statement:

ALTER TABLE orders ENABLE ROW LEVEL SECURITY;

This alone is already a breaking change for non-superuser roles: without an explicit policy, they see zero rows. Every query against that table returns an empty result set. The default is deny, not permit. That’s the right default, and it’s why you add the policy next.

The policy itself is where the isolation lives:

CREATE POLICY tenant_isolation ON orders
    USING (tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::UUID);

current_setting(‘app.current_tenant_id’, true) reads a session-level configuration variable, not a table or a subquery. The value is written once per connection or transaction, stored in the connection’s runtime state and read from there on every query the engine evaluates. Think of it as a register, not a lookup. There’s no index scan, no network round-trip, no function that can be overridden.

The two defensive wrappers matter. The true second argument (the missing_ok flag) makes current_setting return NULL when the variable isn’t set instead of throwing an error. _NULLIF(…, ”)_catches the other common failure mode: a bug or misconfigured middleware that sets the variable to an empty string, which would otherwise crash on ”::UUID with an “invalid input syntax” error. Together they produce the same safe result in both cases: NULL from the expression, a comparison that’s never true and an empty result set. A loud crash is easy to miss in logs. An empty result set with RLS in place is impossible to mistake for a data leak. This behaviour only applies to tables with RLS enabled. Reference tables, public catalogues and anything else you haven’t opted into RLS on keep working normally regardless of whether the tenant variable is set.

Once the policy is in place, the engine rewrites every query from a non-superuser role transparently. A developer writes:

SELECT * FROM orders WHERE status = 'pending';

The engine executes something equivalent to:

SELECT * FROM orders
WHERE status = 'pending'
  AND tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::UUID;

The application sees only the result. The rewrite is invisible at the protocol level. The driver receives rows as if the original query had always contained that filter. The isolation lives below the ORM, below the repository layer, below anything an application author writes or forgets to write.

This is the mechanism that makes the rest of this article possible. Everything that follows (the role configuration, the PHP middleware, the Laravel and Symfony integration patterns) is scaffolding to set that session variable reliably. The hard work is done by the planner.

The role matters: BYPASSRLS

Running your application as a restricted database user, not the superuser, is standard practice for any production deployment, and you’re almost certainly already doing it. Least-privilege roles limit blast radius, prevent accidental schema changes and keep migration tooling separate from runtime access. A typical application role looks like this:

CREATE ROLE app_user WITH LOGIN PASSWORD 'strong_password' NOINHERIT;

GRANT CONNECT ON DATABASE myapp TO app_user;
GRANT USAGE ON SCHEMA public TO app_user;
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO app_user;
ALTER DEFAULT PRIVILEGES IN SCHEMA public
    GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO app_user;

For RLS, this matters for one specific reason: PostgreSQL superusers and any role with the BYPASSRLS attribute silently skip every RLS policy. The policies still exist, no error is raised, but the engine simply doesn’t evaluate them. This is by design, because DBAs and migration tools need unrestricted access. But it means that if your application were to connect as postgres, every CREATE POLICY statement would be inert. Since you’re already running as a restricted role, this isn’t a trap you’re likely to fall into. Just verify that your application role has neither SUPERUSER nor BYPASSRLS, and the policies will be enforced on every query it executes.

Two practical consequences to remember. Don’t run pg_dump as app_user: RLS filters its reads too, so the dump silently comes back short. Run it as the table owner or a superuser. The same rule applies to migrations, data-repair scripts and anything else that legitimately needs to see across tenants. Give those jobs a role that can bypass the policy, and keep that role out of the application’s connection string.

IPC NEWSLETTER

All news about PHP and web development

[mc4wp-simple-turnstile]

 

How do you prevent a tenant from writing into another tenant’s rows?

Everything covered so far has been about reads. USING defines which rows a role can see, and the engine enforces it silently on every SELECT. But isolation isn’t complete until writes are covered as well. A policy without WITH CHECK leaves a gap that’s easy to miss precisely because the symptom is invisible.

Consider what happens when a bug in the application sets the wrong tenant_id on an INSERT: a hardcoded test UUID left in from development, a copy-paste error in a factory, a race condition that reads the wrong session variable. Without a write constraint, PostgreSQL accepts the row. The USING clause then filters it out of every subsequent SELECT the inserting tenant runs, so from the application’s perspective the insert produced nothing. No exception, no visible side effect, no broken test. Meanwhile the row sits in the database fully visible to the tenant whose UUID was accidentally used. The inserting tenant can’t see the data pollution they just created. The victim tenant receives fabricated data they can’t account for. This is a worse failure mode than a read leak, because neither party sees a signal that anything has gone wrong.

The fix is a second expression on the same policy:

CREATE POLICY tenant_isolation ON orders
    FOR ALL
    USING (tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::UUID)
    WITH CHECK (tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::UUID);

WITH CHECK is a constraint evaluated during execution, against the new row values, before the write completes. It runs before the row lands, not after. On INSERT and UPDATE, PostgreSQL tests whether the incoming row would satisfy the expression. If it doesn’t, the statement is aborted immediately with ERROR: new row violates row-level security policy. The row is never written. The transaction rolls back at that point.

The practical consequence is that the silent corruption scenario becomes a loud, visible failure. The same bug that previously produced an undetectable data leak now causes an immediate exception. That exception surfaces in logs, in error monitoring, in the developer’s console during testing. The bug goes from something a team might discover weeks later during a compliance audit to something a developer hits on the first test run. That shift in feedback latency is the difference between a security incident and a caught defect.

The expression in WITH CHECK is deliberately identical to USING. For tenant isolation, the invariant is the same in both directions: a row either belongs to the current tenant or it does not. If your use case ever involves policies where read access and write constraints differ (for example, a role that can read a shared reference table but must never write to it) the clauses can diverge. For the common case of strict per-tenant ownership, keeping them in sync is correct and the intent is self-documenting.

Reads are filtered by USING. Writes are validated by WITH CHECK. The database layer is now a complete isolation boundary, one that doesn’t depend on the application having a good day.

How does the tenant context reach the database without touching business logic?

The application needs to do exactly one thing: tell the database which tenant is active. The entire PHP integration reduces to reliably setting a single session variable.

The Tenant Context

You need somewhere to hold the current tenant ID. A read-only DTO works, an associative array works, a single-property object works. Pick one and stay consistent. The examples below use:

readonly class TenantContext
{
    public function __construct(
        public string $id,
        public string $name,
    ) {}
}

Populate it once per request from whatever your auth layer gives you (JWT claim, subdomain, header, user lookup) and register it as a request-scoped service. That’s it. Business logic never sees it. The middleware below is the only code that reads from it.

YOU LOVE PHP?

Explore the PHP Core Track

 

Where does SET LOCAL fit in the request lifecycle?

TenantContext holds the tenant ID. The middleware is what puts it into the database session, and it does this in the only place that matters: at the transaction boundary.

The pattern is straightforward. Intercept every request, begin a transaction, set the session variable, let the request proceed, commit on success, rollback on failure. That’s the entire job. Here is the complete implementation:

final readonly class TenantMiddleware
{
    public function __construct(
        private PDO $pdo,
        private TenantContext $tenant,
    ) {}

    public function handle(callable $next): mixed
    {
        $this->pdo->beginTransaction();

        try {
            $stmt = $this->pdo->prepare(
                "SELECT set_config('app.current_tenant_id', :tenant_id, true)"
            );
            $stmt->execute(['tenant_id' => $this->tenant->id]);

            $response = $next();
            $this->pdo->commit();

            return $response;
        } catch (\Throwable $e) {
            $this->pdo->rollBack();
            throw $e;
        }
    }
}

This is the single point of tenant awareness for HTTP requests. Queue workers and CLI commands need their own entry-point setup (covered in the framework sections below) but the principle is the same: a thin wrapper at the entry point, and everything downstream is tenant-blind.

A note on the SQL. set_config(‘app.current_tenant_id’, value, true) is the function-form equivalent of SET LOCAL app.current_tenant_id = value, with one practical advantage: you can bind parameters into it. SET and SET LOCAL are utility statements, and PostgreSQL rejects them from PREPARE. That breaks anywhere your driver uses real server-side prepared statements, which is the modern default for PDO pgsql. set_config() is a regular function call inside a SELECT, so parameter binding works in every driver configuration. The third argument (is_local) is what makes it transaction-scoped instead of session-scoped.

Why transaction-scoped and not session-scoped? The distinction is the lifetime of the variable. SET (without LOCAL) persists for the entire database session, which means it persists across connection reuse. When a connection pool hands that connection to the next request, it arrives with the previous tenant’s ID still sitting in the session variable. The next tenant’s queries run with a stale, wrong context. RLS filters the wrong rows. The bug is silent.

SET LOCAL scopes the variable to the current transaction. When the transaction ends, whether by commit or rollback, the variable resets automatically. There’s no cleanup step to remember, no risk of a stale value bleeding into the next request. The variable can’t outlive the transaction that set it. The database engine guarantees this, and the guarantee holds regardless of connection pooling, regardless of exceptions, regardless of whatever the application code does between beginTransaction and commit.

The scope of protection this provides becomes clear when you see what the rest of the application doesn’t have to do. Downstream code never mentions tenants. A service that calls SELECT * FROM orders gets only the current tenant’s orders, because the database’s RLS policy reads the session variable set by this middleware and filters everything else out automatically. Business logic stops carrying tenant plumbing. The middleware is the single point of tenant awareness, and every other layer stops needing to care.

The payoff is concrete. Remember getRecentOrders() from earlier, the function that returned seven days of orders for every tenant in the database?

function getRecentOrders(PDO $pdo): array
{
    $stmt = $pdo->query('SELECT * FROM orders WHERE created_at > NOW() - INTERVAL \'7 days\'');
    return $stmt->fetchAll();
}

That function is unchanged. No modifications, no added WHERE tenant_id = ?, no base class, no scope injection. Run it now, with the middleware setting app.current_tenant_id and the RLS policy in place, and it returns only the current tenant’s recent orders. Run it without the middleware, with the session variable unset, and it returns nothing at all. Either way, no tenant sees another tenant’s data. The bug is gone without touching the buggy code. The database enforces what the developer forgot to write.

This is the architecture the rest of the article builds on. One session variable, one policy and a thin middleware at each entry point. Every query in the application is tenant-safe by construction.

Warning: Connection Pooling

When using PgBouncer in transaction mode (or Swoole’s persistent connection pools) a SET (without LOCAL) persists for the lifetime of the database session, not the request. If the pool reuses that connection for a different tenant, the stale app.current_tenant_id is still set, and RLS will filter on the wrong tenant silently. Always use SET LOCAL inside a BEGIN…COMMIT block, exactly as the middleware above does. SET LOCAL is automatically cleared when the transaction ends, regardless of how the connection is reused afterwards. If you’re running PgBouncer in session mode, plain SET would also be safe, but SET LOCAL works correctly in both modes, so prefer it universally and eliminate the risk entirely.

 

Performance and indexing

The concern is legitimate. RLS appends a tenant_id filter to every query without you writing it. A reasonable developer looks at that and asks: is the planner doing extra work on every request? The short answer is no, if you index correctly. Without the right indexes, the planner falls back to sequential scans. With them, performance is identical to hand-written WHERE clauses. The overhead sits entirely in whether the predicate can be satisfied by an index.

The fix is straightforward: create composite indexes with tenant_id as the leading column.

-- Before: index on the application column alone
CREATE INDEX idx_orders_status ON orders (status);

-- After: tenant_id leads, planner satisfies both RLS and application filter
CREATE INDEX idx_orders_tenant_status ON orders (tenant_id, status);
CREATE INDEX idx_orders_tenant_created ON orders (tenant_id, created_at);

This is B-tree prefix optimization. An index on (tenant_id, status) is sorted first by tenant_id, then by status within each tenant’s range. A query with WHERE tenant_id = X AND status = Y can drop directly into the correct leaf page and read a narrow, contiguous range. The RLS predicate and the application’s own filter are both covered by the same index structure in a single scan. The planner doesn’t treat the RLS predicate as special. It arrives at query planning time as just another filter condition, analyzed alongside everything else the developer wrote. If the index covers it, it gets used.

You can verify this directly with EXPLAIN ANALYZE:

EXPLAIN ANALYZE SELECT * FROM orders WHERE status = 'pending';

-- With RLS active, the planner output shows:
-- Index Scan using idx_orders_tenant_status on orders
--   Index Cond: (tenant_id = 'abc-123'::uuid AND status = 'pending')
--   Rows Removed by Filter: 0
--   Execution Time: 0.045 ms

The index condition includes both columns: tenant_id from the RLS policy and status from the application query. The planner merged them and found a single index that covers both. There’s no additional filter step, no recheck on the heap, no sequential scan of the tenant’s rows. The query touches only the rows it needs.

The key insight is that RLS achieves performance parity with hand-written WHERE clauses once the index is in place. The only real cost is maintaining the composite index, but that’s a cost you’d carry anyway. Any table that partitions data by tenant needs indexes that lead with tenant_id regardless of whether RLS is involved. The policy doesn’t create a new indexing requirement. It just makes the existing requirement visible. Get the indexes right, and RLS adds zero measurable overhead to your queries.

How do you make a username unique per tenant but not globally?

RLS handles isolation at the row-visibility layer. Schema constraints handle integrity at the write layer. The two concerns are independent, and uniqueness is where teams most often get the schema wrong.

The naive approach is a plain unique constraint on the column:

-- Wrong for tenant-facing values
ALTER TABLE users ADD CONSTRAINT uq_users_email UNIQUE (email);

This prevents Tenant A and Tenant B from both having [email protected]._ In almost every multi-tenant SaaS application, that’s the wrong rule. Each tenant is an isolated world. [email protected] signing up with Tenant A has no relationship whatsoever to [email protected] signing up with Tenant B. A global uniqueness constraint treats two separate namespaces as one and causes legitimate registrations to fail with a constraint violation that the application can’t meaningfully explain to the user.

The fix is to scope the constraint to the tenant:

-- Per-tenant uniqueness: same email allowed across tenants
ALTER TABLE users ADD CONSTRAINT uq_users_tenant_email
    UNIQUE (tenant_id, email);

PostgreSQL enforces this at write time, before RLS is even consulted. The uniqueness check is scoped to each (tenant_id, email) pair, so the same email address can exist in multiple tenants without conflict. The constraint is still enforced (two users within the same tenant can’t share an email) but the isolation boundary is correct.

When global uniqueness is right. Some values genuinely must be unique across the entire system regardless of tenant. SSO subject identifiers (the sub claim from an identity provider), OAuth client IDs and external API keys issued by your platform all fall into this category. These are system-level identifiers, not tenant-facing values. An SSO sub must map to exactly one user record across all tenants. Scoping it to (tenant_id, sub) would allow the same identity provider subject to be linked to accounts in multiple tenants, which breaks the login flow. For these, a plain UNIQUE(sub) is the correct constraint.

The rule of thumb is: if the value is tenant-facing (usernames, email addresses, URL slugs, invoice numbers, SKUs) scope the constraint to (tenant_id, column). If the value is system-facing (SSO subject IDs, OAuth client IDs, anything issued or verified by infrastructure that has no concept of tenants) keep it globally unique. Applying this distinction consistently means your schema enforces the right invariants at the right boundaries, and uniqueness violations produce errors that are meaningful rather than accidental.

 

How do existing frameworks plug into this pattern?

The core pattern is framework-agnostic. We proved it with raw PDO. The database does the heavy lifting. The framework just needs to set one session variable. Here’s how Laravel and Symfony do it with minimal glue code.

Laravel / Eloquent

The first thing to notice is what you can remove. If your application uses a BelongsToTenant trait, a global scope that injects WHERE tenant_id = ? into every Eloquent query, or a base model class that enforces tenant filtering, you no longer need any of it. Those mechanisms exist to compensate for the database having no opinion about tenant boundaries. Now that the database enforces isolation through RLS, they’re redundant. Removing them simplifies every model, eliminates the _withoutGlobalScope()_ workarounds that accumulate whenever a legitimate query needs to cross tenant lines and erases an entire category of “did I forget to apply the scope?” bugs. Less code enforcing the same guarantee, at a lower layer.

The replacement is a single middleware class:

class SetTenantContext
{
    public function __construct(private TenantContext $tenant) {}

    public function handle(Request $request, Closure $next): Response
    {
        return DB::transaction(function () use ($request, $next) {
            DB::statement("SELECT set_config('app.current_tenant_id', ?, true)", [$this->tenant->id]);

            return $next($request);
        });
    }
}

The structure mirrors the raw PDO middleware shown earlier exactly: begin transaction, set the variable with SET LOCAL, let the request proceed, commit or rollback. The only difference is the surface, DB::beginTransaction() and DB::statement() instead of $pdo->beginTransaction()` and `$pdo->prepare(). Laravel’s database abstraction layer calls the same PostgreSQL wire protocol underneath.

Register it globally in bootstrap/app.php and every route in the application is covered automatically. No per-controller annotation, no opt-in attribute, no base controller to extend.

That covers HTTP. Queue workers, Artisan commands and scheduled tasks don’t pass through HTTP middleware, but they still execute database queries. The same SET LOCAL call needs to happen there. For queued jobs, Laravel’s job middleware is the natural hook. Store the tenant_id on the job payload at dispatch time, then apply it at execution time:

{
    public function __construct(private string $tenantId) {}

    public function handle(object $job, callable $next): void
    {
        DB::transaction(function () use ($job, $next) {
            DB::statement("SELECT set_config('app.current_tenant_id', ?, true)", [$this->tenantId]);
            $next($job);
        });
    }
}

Each job carries its tenant context explicitly, with no reliance on auth() or the request object. A job wires it up by storing the tenant ID as a property and returning the middleware from its middleware() method:

class ProcessOrder implements ShouldQueue
{
    public function __construct(
        private string $orderId,
        private string $tenantId,
    ) {}

    public function middleware(): array
    {
        return [new ApplyTenantContext($this->tenantId)];
    }

    public function handle(): void
    {
        // tenant context is already set — just write business logic
    }
}

Artisan commands have no middleware layer. For tenant-scoped commands, the most straightforward approach is a CommandStarting event listener that reads a –tenant option and sets the session variable, or a base command class that wraps execution in a transaction with SET LOCAL. Neither is as clean as the HTTP or job middleware. CLI is the one context where you’re still writing explicit tenant-aware code. The upside is that even if you forget, RLS doesn’t silently return all tenants’ data: without app.current_tenant_id set, the session variable is empty and the policy filters out every row. The failure mode is an empty result set, not a data leak.

The proof is in what Eloquent’s query builder now does without any modification. Order::all(), Order::where(‘status’, ‘pending’)->get(), DB::table(‘orders’)->get()(which bypasses Eloquent entirely and drops to the query builder) and even a raw DB::select(‘SELECT * FROM orders’) all return only the current tenant’s orders. The RLS policy doesn’t distinguish between how the query arrived. It evaluates the session variable against every row, regardless of whether the query came from Eloquent, the query builder or a raw SQL string.

That last point is the one that matters most. The failure modes described at the start of this article (the developer who reaches for DB::select() to write a report, the admin dashboard that skips the ORM for performance) are now safe. Queue jobs are covered by the job middleware. CLI commands require explicit setup, but even if you forget, the failure mode is an empty result set rather than a data leak. In every case, the database enforces the boundary below the application.

Symfony / Doctrine

The same pattern, wired for Doctrine 3.x. Where Laravel intercepts at the HTTP middleware layer, Doctrine provides a lower-level hook (the DBAL Middleware interface) that wraps the database driver itself. The result is identical: one place in the application sets app.current_tenant_id, and nothing else needs to know it exists.

A Doctrine DBAL Middleware sits between the ORM and the underlying driver. You implement two small classes: one that wraps the driver, and one that wraps the connection the driver returns. But first, a wiring detail. Symfony instantiates the Doctrine middleware stack when the kernel boots, which can happen before any request event has populated TenantContext. Injecting TenantContext directly would blow up in CLI contexts (migrations, cache warmup, console commands) where no request ever fires. The fix is to defer resolution behind a small interface:

interface TenantProviderInterface
{
    public function getTenantId(): ?string;
}

A concrete implementation reads from wherever the tenant lives in the current context (request attributes, a scoped service, the security token). The driver never touches that logic. It just asks the provider on every connection and proceeds based on what it gets back.

TenantConnectionMiddleware then handles the outer layer:

final class TenantConnectionMiddleware implements Middleware
{
    public function __construct(
        private readonly TenantProviderInterface $tenantProvider,
    ) {}

    public function wrap(Driver $driver): Driver
    {
        return new TenantDriver($driver, $this->tenantProvider);
    }
}

TenantDriver intercepts connect() and executes SET LOCAL before returning the connection:

final class TenantDriver extends AbstractDriverMiddleware
{
    public function __construct(
        Driver $driver,
        private readonly TenantProviderInterface $tenantProvider,
    ) {
        parent::__construct($driver);
    }

    public function connect(array $params): Connection
    {
        $connection = parent::connect($params);
        $tenantId = $this->tenantProvider->getTenantId();

        if ($tenantId) {
            $stmt = $connection->prepare(
                "SELECT set_config('app.current_tenant_id', ?, true)"
            );
            $stmt->execute([$tenantId]);
        }

        return $connection;
    }
}

The null check matters. If the provider returns null (no active request, no populated context), the driver skips the set_config call entirely. Queries against RLS-protected tables return empty result sets, because the policy’s default-deny kicks in. Queries against tables without RLS continue to work normally. The skipped call only affects tables that consult the session variable through a policy. An empty set on the protected tables is the correct failure mode for a query that runs without tenant context.

Register TenantConnectionMiddleware as a service and tag it with doctrine.middleware. Doctrine wires it into every connection automatically.

One other constraint worth noting: SET LOCAL only persists for the duration of the current transaction. For the middleware to have any effect, Doctrine must be operating inside a transaction when the connection is used. In Symfony applications this is typically arranged by wrapping the request in a transaction via a kernel listener or Doctrine’s own transaction middleware. One of those approaches should already be present if you’re using Doctrine seriously, and no additional wiring is needed here.

Messenger workers. The DBAL Middleware hooks at connect() time, but in a long-running worker the connection is established once and reused across messages. That means the DBAL Middleware fires only on the first message. Subsequent messages inherit a connection where SET LOCAL has already been reset by the previous commit. The DBAL Middleware alone isn’t enough here.

The fix is a self-contained Messenger middleware that manages its own transaction boundary, mirroring the Laravel job middleware pattern. Start with a stamp to carry the tenant ID through the transport:

readonly class TenantStamp implements StampInterface
{
    public function __construct(public string $tenantId) {}
}

Attach it at dispatch time: $bus->dispatch(new ProcessOrder($orderId), [new TenantStamp($tenantId)]). Then a Messenger middleware reads the stamp, opens a transaction and sets the session variable before the handler runs:

final class TenantMessengerMiddleware implements MiddlewareInterface
{
    public function __construct(private Connection $connection) {}

    public function handle(Envelope $envelope, StackInterface $stack): Envelope
    {
        $stamp = $envelope->last(TenantStamp::class);
        if (!$stamp) {
            return $stack->next()->handle($envelope, $stack);
        }

        $this->connection->beginTransaction();
        try {
            $this->connection->executeStatement(
                "SELECT set_config('app.current_tenant_id', ?, true)",
                [$stamp->tenantId]
            );
            $envelope = $stack->next()->handle($envelope, $stack);
            $this->connection->commit();
            return $envelope;
        } catch (\Throwable $e) {
            $this->connection->rollBack();
            throw $e;
        }
    }
}

Each message gets its own transaction with a fresh SET LOCAL. When the transaction commits, the variable resets. No tenant bleed between messages, regardless of how long the worker runs.

Console commands. Accept the tenant ID as an argument or option, begin a transaction and execute SET LOCAL before running any queries. Same pattern as every other entry point, just wired manually. Unlike Messenger, there’s no dispatch-side mechanism. The command must receive the tenant ID explicitly. As with the Laravel side, the safe default applies: without app.current_tenant_id set, the policy filters out every row rather than returning all tenants’ data.

What matters is what you no longer write. $repo->findAll() returns only the current tenant’s rows. $repo->findBy([‘status’ => ‘pending’]) returns only the current tenant’s pending rows. DQL queries and the QueryBuilder are both subject to the same RLS policy. Doctrine emits standard SQL and the planner adds the tenant predicate transparently, regardless of the query origin. No custom repository base class, no TenantAwareRepository interface, no Doctrine SQL Filter registered in orm.yaml. The SQL Filters that you may have installed as the traditional Doctrine approach to multi-tenancy can be removed entirely, for the same reason Global Scopes could be removed from Laravel: the enforcement now lives below the ORM, in the database, where it can’t be forgotten, bypassed or quietly absent from a raw query.

IPC NEWSLETTER

All news about PHP and web development

[mc4wp-simple-turnstile]

 

The result

Here is getRecentOrders() again, the function from the opening section, the one that silently returned seven days of orders for every tenant in the database:

function getRecentOrders(PDO $pdo): array
{
    // Spot the bug: no tenant_id filter
    $stmt = $pdo->query('SELECT * FROM orders WHERE created_at > NOW() - INTERVAL \'7 days\'');
    return $stmt->fetchAll();
}

Not a single character has changed. The function is identical to the version that silently returned seven days of orders for every tenant in the database, and it’s now safe. That is the entire point.

Before, security depended on every developer, every query, every time. One forgotten WHERE clause was a data leak. The enforcement lived in application code written by humans under deadline pressure, scattered across repositories, silently absent from raw SQL and invisible to any tool that could reliably check it.

After, security is enforced by the database engine. Your business logic is tenant-blind. There’s one session variable, one policy and a small middleware at each entry point. Every query (whether it comes from Eloquent, Doctrine, the query builder, a raw PDO::query() call or a report written by a contractor who never read the onboarding docs) passes through the same enforcement layer, because that layer sits below all of them.

What you built isn’t large: RLS policies on the tables that need isolation, a restricted role without BYPASSRLS, a thin middleware layer that sets one session variable at each entry point (HTTP requests, queue jobs, CLI commands) and a handful of small framework classes to wire it up. The surface area is small by design. The smaller the surface, the fewer the places a mistake can hide.

By moving isolation to the database layer, you reduce the attack surface for tenant data leaks to a set of SQL policies and a small framework-level setup. Services have no tenant parameters. Repositories have no scoping logic. Event handlers, queued jobs and admin endpoints can’t leak cross-tenant data. Even if a developer forgets to wire the tenant context, the result is an empty set, not another tenant’s rows. The database doesn’t give your code a choice about which rows it can see.

Your application’s only job now is to identify the tenant and hand that identity to the middleware. Everything else runs below your code, at the protocol level, before a single row reaches a PHP process.

Developers will still make mistakes. The database has stopped caring.

The post Invisible Isolation: Multi-Tenancy with PostgreSQL RLS appeared first on International PHP Conference.

]]>
PHP RAG Tutorial: Build an AI Agent with Neuron AI https://phpconference.com/blog/php-rag-tutorial-neuron-ai/ Thu, 30 Jul 2026 13:13:39 +0000 https://phpconference.com/?p=210077 This article explores how developers can bridge the gap between RAG theory and practical PHP implementation. Traditionally, building reliable RAG systems requires manually managing document parsing, vector embeddings, and database storage, often resulting in brittle architectures. I will demonstrate how the Neuron AI framework simplifies this process by providing an abstracted, built-in data pipeline that handles vector management and similarity searches. The piece covers creating a custom RAG class, configuring LLM and embedding providers, and utilizing the Data Loader module for automatic text chunking and persistence. Finally, we look at maintaining production-grade implementations through built-in observability to inspect the internal decisions of AI agents.

The post PHP RAG Tutorial: Build an AI Agent with Neuron AI appeared first on International PHP Conference.

]]>

Note: This podcast and video were created using AI. They are based on the original content and technical insights provided by the author of the blog post.

 

Implementing Retrieval-Augmented Generation (RAG) is often the first wall PHP developers hit when moving beyond simple chat scripts. While the concept of “giving an LLM access to your own data” is straightforward, the tasks required to make it work reliably in a PHP environment can be frustrating. You have to manage document parsing, vector embeddings, storage in a vector database, and the final prompt orchestration. Most developers end up trying to glue several disparate libraries together, only to find that the resulting system is brittle and hard to maintain.

IPC NEWSLETTER

All news about PHP and web development

[mc4wp-simple-turnstile]

 

Neuron was designed to eliminate this friction. It provides a built-in RAG module that handles the heavy lifting of the data pipeline, allowing you to focus on your agent’s logic rather than the mechanics of vector management and similarity search. In a typical scenario, like building a support agent that needs to “read” your company’s internal documentation, you don’t want to manually handle the chunking of text or the API calls to OpenAI’s embedding models. Neuron abstracts these into a fluent workflow where you define a “Data Source,” and the framework ensures the most relevant snippets of information are injected into the agent’s context window at runtime.

Understanding the Foundation: What RAG Really Means

Retrieval Augmented Generation breaks down into three critical components that work in harmony to solve a fundamental problem in AI: how do we give language models access to specific, up-to-date, or proprietary information that wasn’t part of their original training data?

The “G” part of the RAG acronym is straightforward. We’re talking about Generative AI models like GPT, Claude, Gemini, or any large language model that can produce human-like text responses. These models are incredibly powerful, but they have a significant limitation: they only know what they were trained on, and that knowledge has a cutoff date. They can’t access your company’s internal documents, your personal notes, or real-time information from your databases.

This is where the “Retrieval Augmented” component becomes transformative. Instead of relying solely on the model’s pre-trained knowledge, we augment its capabilities by retrieving relevant information from external sources at the moment of generation. Think of it as giving your AI agent a research assistant that can instantly find and present relevant context before answering any question.

Below you can see an example of how this process should work:

RAG workflow in which an AI agent queries a knowledge source and passes the retrieved context to an LLM.

The Magic Behind Embeddings and Vector Spaces

To understand how retrieval works in practice, we need to dive into embeddings—a concept that initially seems abstract but becomes intuitive once you see it in action. An embedding is essentially a mathematical representation of text, images, or any data converted into a list of numbers called a vector. What makes this powerful is that similar concepts end up with similar vectors, creating a mathematical space where related ideas cluster together.

Three-dimensional embedding space showing semantically related terms such as wolf, dog, and cat clustered separately from apple and banana.

When I first started working with Neuron AI, I was amazed by how this actually works in practice. Imagine you have thousands of documents—customer support tickets, product manuals, internal wikis, research papers. Traditional keyword search would require exact matches or clever Boolean logic to find relevant information. But with embeddings, you can ask a question like “How do I troubleshoot connection issues?” and the system will find documents about network problems, authentication failures, and server timeouts, even if those documents never use the exact phrase “connection issues.”

The process works by converting both your question and all your documents into these mathematical vectors. The system then calculates which document vectors are closest to your question vector in this multi-dimensional space. It’s like having a librarian who understands the meaning and context of your request, not just the literal words you used.

YOU LOVE PHP?

Explore the PHP Core Track

 

The Challenge of real RAG Implementations

The conceptual understanding of RAG is one thing; actually building a working system is another challenge entirely. This is where the complexity really emerges, and it’s why Neuron is such a valuable tool for PHP developers entering this space.

The ecosystem involves multiple moving parts: you need to chunk your documents effectively, generate embeddings using appropriate models, store and index those embeddings in a vector database, implement semantic search functionality, and then orchestrate the retrieval and generation process seamlessly.

RAG architecture showing a user prompt converted into embeddings, searched in a vector store, and supplied to an LLM as knowledge.

Each of these steps involves technical decisions that can significantly impact your agent’s performance (speed and quality of responses). How do you split long documents into meaningful chunks? Which embedding model works best for your domain? How do you handle updates to your knowledge base? How do you balance retrieval accuracy with response speed? These questions become more pressing when you’re building production systems that need to scale and perform reliably.

In the detailed implementation guide that follows, we’ll explore how Neuron simplifies this complex orchestration, providing PHP developers with tools and patterns that make RAG agent development both accessible and powerful.

Install Neuron AI

To get started, you can install the core framework and the RAG components via Composer:

composer require neuron-core/neuron-ai

Create a RAG Agent

To create a RAG, Neuron provides you with a dedicated class you can extend to orchestrate the necessary components such as the AI provider, vector store, and embeddings provider.

First, let’s create the RAG class:

namespace App\Neuron;

use NeuronAI\Providers\AIProviderInterface;
use NeuronAI\Providers\Anthropic\Anthropic;
use NeuronAI\RAG\Embeddings\EmbeddingsProviderInterface;
use NeuronAI\RAG\Embeddings\OpenAIEmbeddingsProvider;
use NeuronAI\RAG\RAG;
use NeuronAI\RAG\VectorStore\FileVectorStore;
use NeuronAI\RAG\VectorStore\VectorStoreInterface;

class MyRAG extends RAG
{
    protected function provider(): AIProviderInterface
    {
        return new Anthropic(
            key: 'ANTHROPIC_API_KEY',
            model: 'ANTHROPIC_MODEL',
        );
    }
    
    protected function embeddings(): EmbeddingsProviderInterface
    {
        return new VoyageEmbeddingsProvider(
            key: 'VOYAGE_API_KEY',
            model: 'VOYAGE_MODEL'
        );
    }
    
    protected function vectorStore(): VectorStoreInterface
    {
        return new FileVectorStore(
            directory: __DIR__,
            name: 'demo'
        );
    }
}

In the example above, we provided the RAG with a connection to:

  • The LLM (Anthropic in this case)
  • The Embedding provider: The service able to transform text into vector embeddings
  • The vector store to persist the generated embeddings and perform document retrieval

Be sure to provide the appropriate information to connect with these services. You have plenty of options for each of these components. You can use local systems or managed services, so feel free to explore the documentation to choose your preferred ones.

IPC NEWSLETTER

All news about PHP and web development

[mc4wp-simple-turnstile]

 

Feed Your RAG With A Knowledge Base

At this stage, the vector store behind our RAG agent is empty. If we send a prompt to the agent, it will be able to respond, leveraging only the underlying LLM training data.

use NeuronAI\Chat\Messages\UserMessage;

$response = MyRAG::make()
    ->chat(
        new UserMessage('What size is the door handle on our top car model?')
    );
    
echo $response->getContent();

// I don't really know specifically about your top car model. Do you want to provide me with additional information?

We need to feed the RAG with some knowledge so that it’s able to respond to questions about private information outside its default training data.

Neuron AI Data Loader

To build a structured AI application, you need the ability to convert all the information you have into text so you can generate embeddings, save them into a vector store, and then feed your Agent to answer the user’s questions.

Data retrieval workflow connecting a PHP AI agent to an embeddings provider, vector store, and large language model.

Neuron has a dedicated module to simplify this process. In order to answer the previous question (What size is the door handle on our top car model?), we can feed the RAG with documents (Markdown files, PDFs, HTML pages, etc.) containing such information.

You can do it in just a few lines of code:

use NeuronAI\RAG\DataLoader\FileDataLoader;

// Use the file data loader component to process documents
$documents = FileDataLoader::for(__DIR__)
        ->addReader('pdf', new \NeuronAI\RAG\DataLoader\PdfReader())
        ->addReader(['html', 'xhtml'], new \NeuronAI\RAG\DataLoader\HtmlReader())
        ->getDocuments();

MyRAG::make()->addDocuments($documents);

As you can see from the example above, you can just point the data loader to a directory containing all the files you want to load into the vector store, and it automatically does the following:

  • Extract all text inside files
  • Chunk the content with a splitting strategy
  • Pass all the documents into the RAG to generate embeddings and finally persist all this information into the vector store.

It’s just an example to demonstrate how you can create a complete data pipeline for your agentic application in 5 lines of code. You can learn more about the extensibility and customization opportunities for readers and splitters here.

 

Talk to the chatbot

Imagine having previously populated the vector store with the knowledge base you want to connect to the RAG agent, and now you want to ask questions. To start the execution of a RAG, you call the chat() method:

use App\Neuron\MyRAG;
use NeuronAI\Chat\Messages\UserMessage;

$response = MyRAG::make()->chat(
    new UserMessage('What size is the door handle on our top car model?')
);
    
echo $response->getContent();

// Based on 2025 sales results, the top car model in your catalog is XXX...

Monitoring & Debugging

Many of the Agents you build with NeuronAI will contain multiple steps with multiple invocations of LLM calls, tool usage, access to external memories, etc. As these applications get more and more complex, it becomes crucial to be able to inspect exactly what your agent is doing and why. Why is the model taking certain decisions? What data is the model reacting to?

The Inspector team designed Neuron AI with built-in observability features so you can monitor AI agents while running, helping you maintain production-grade implementations with confidence.

To start monitoring your agentic systems, you need to add the INSPECTOR_INGESTION_KEY variable in your application environment file. Authenticate on Inspector.dev to create a new one.

INSPECTOR_INGESTION_KEY=nwse877auxxxxxxxxxxxxxxxxxxxxxxxxxxxx

When your agents are being executed, you will see the details of their internal steps on the Inspector dashboard.

Inspector dashboard displaying the execution timeline, nested workflow steps, duration, and memory usage of an AI agent.

IPC NEWSLETTER

All news about PHP and web development

[mc4wp-simple-turnstile]

 

Moving Forward

The complexity of orchestrating embeddings, vector databases, and language models might seem a bit daunting, but remember that every expert was once a beginner wrestling with these same concepts.

The next step is to dive into the practical implementation. Neuron AI framework is designed specifically to bridge the gap between RAG theory and production-ready agents, handling the complex integrations while giving you the flexibility to customize the behavior for your specific use case. Start building your first RAG agent today and discover how powerful context-aware AI can transform your applications.

Whether you’re just getting started with AI agents or looking to take your skills to the next level, the resources in [1], [2], and [3] provide practical guidance to help you move forward.

Further Readings

[1] Repository
[2] Newsletter
[3] Start With AI Agents In PHP

The post PHP RAG Tutorial: Build an AI Agent with Neuron AI appeared first on International PHP Conference.

]]>
Strategy vs Decorator in PHP: Choosing the Right Design Pattern https://phpconference.com/blog/strategy-vs-decorator-php-design-patterns/ Wed, 08 Jul 2026 12:20:33 +0000 https://phpconference.com/?p=210035 The article explains how the Strategy and Decorator design patterns help developers build maintainable PHP applications by replacing complex conditional logic with flexible object composition. You’ll learn when to use each pattern, how they support SOLID principles and testing, and why Decorators are often more flexible than Traits for adding behavior dynamically.

The post Strategy vs Decorator in PHP: Choosing the Right Design Pattern appeared first on International PHP Conference.

]]>
In every developer’s life, there comes a time when you’re faced with a challenge: add another if and worry about it next time around, or go the extra mile and put a more maintainable structure in place. This choice marks the difference between a junior and senior developer.

More so, senior developers know that there’s no need to reinvent the wheel at every step. They know that design patterns exist precisely for this reason: to produce elegant and maintainable solutions. However, there’s a catch. Choose wrong, and you’ll find yourself trapped in a labyrinth even worse than what you had to begin with.

IPC NEWSLETTER

All news about PHP and web development

[mc4wp-simple-turnstile]

 

Strategy and Decorator

In this article, I will discuss the use of two patterns, Strategy and Decorator, which, at first glance, seem to solve the same problem. But if you look closely, you’ll discover that each one has its particular scope. Let’s explore how you can use them to make your code maintainable over the long run through real-world examples and some tips on when to use each one.

What do they have in common?

To begin with, they both belong in the behavioral category. They deal with problems related to the proper distribution of responsibilities among objects. Other patterns deal with different sets of problems, such as those in the creational category, which provide different approaches to how to create new instances of classes, or those in the structural category, which help when the issue is about combining objects into large structures.

Back to our scope, both the Strategy and Decorator patterns address the challenge of determining, at runtime, which logic should be applied to a particular case. This gives you a first clue about when these patterns are useful: whenever multiple approaches are available and the appropriate choice cannot be determined until the application is actually running.

What’s different about them?

Strategy is about choosing one possibility from a pool of interchangeable options, while Decorator is about choosing many composable possibilities. In other words, you can think of Strategy as a big “or” and of Decorator as a big “and”. Sounds confusing? Let’s explain it with some examples.

YOU LOVE PHP?

Explore the PHP Core Track

 

A real-life example

Say you have a scenario like this: you are developing an application for a wealth management firm. They manage a portfolio of financial assets on behalf of their clients. You have a data model that looks roughly like this:

<?php

abstract readonly class Security
{
   public string $isin;

   public function __construct(string $isin)
   {
       $this->isin = $isin;
   }
}

<?php

readonly class Stock extends Security
{
   public string $ticker;

   public function __construct(string $ticker, string $isin)
   {
       parent::__construct($isin);
       $this->ticker = $ticker;
   }
}

<?php

readonly class Bond extends Security
{
   public string $description;

   public function __construct(string $isin, string $description)
   {
       parent::__construct($isin);
       $this->description = $description;
   }
}

<?php

readonly class MutualFund extends Security
{
   public string $name;

   public function __construct(string $isin, string $name)
   {
       parent::__construct($isin);
       $this->name = $name;
   }
}

To produce a particular report, your application needs to know the prices the assets held by clients had at random past dates. Sounds pretty simple, doesn’t it? It’s just about iterating over the collection of assets and, for each one, fetching its price at the specific date.

But you know what they say… the devil’s in the details.

Let’s assume there are three APIs you can query to get the information you need, but not everyone will have data for every security. To make things a little more complicated (and realistic), there’s no rule to determine which one will. And, of course, each API has a different contract you need to abide by.

Let’s take a simple approach: query each API until you get a positive result. Since you can’t know in advance which one will be a hit, the order is not really relevant here. A naive first attempt at it could look like this:

<?php

use Mauro\Strategy\Security;
use Mauro\Strategy\SecurityPrice;

function getPriceFor(Security $security, DateTimeInterface $date): ?SecurityPrice
{
   // Try to fetch price from first API

   if ($price) {
      
       return $price;
   }

   // Try to fetch price from second API

   if ($price) {
       return $price;
   }

   // Try to fetch price from third API

   if ($price) {
       return $price;
   }

   return null;
}

And it’ll work. But, as things move forward, it will get messy. For starters, you’ll have too much responsibility buried inside the getPriceFor function, making the code rather difficult to read and reason about. You could work around this by extracting the logic of interacting with each API into its own private method. That would be a step in the right direction, but it won’t make much progress.

More importantly, you need to think about the future: what will happen when a new data source becomes available? Or when an API changes its contract? Or you find out that one of them produces the expected result 85% of the time? In any of these situations, you’ll have to revisit the code you wrote and tested months ago.

And that’s something you definitely don’t want to do. Once something is working, you want to leave it as it is and forget about it. In fact, writing those tests in the first place is not going to be easy (or pleasant). This is the exact scenario where the Strategy pattern comes to the rescue: it provides a generalisation you can extend indefinitely.

IPC NEWSLETTER

All news about PHP and web development

[mc4wp-simple-turnstile]

 

The Strategy Pattern

In practice, this means having a class to interact with each API and giving your function a collection of objects it can use without worrying about the little details.

The interface

Start by defining an interface that all concrete strategies will implement:

<?php

namespace Mauro\Strategy;

use DateTimeInterface;

interface SecurityPriceFetcher
{
   function fetch(Security $security, DateTimeInterface $date): float;
}

The strategies

Now each API integration becomes a class of its own, encapsulating its specific interaction logic:

<?php

namespace Mauro\Strategy;

use DateTimeInterface;

class FirstAPIPriceFetcher implements SecurityPriceFetcher
{

   function fetch(Security $security, DateTimeInterface $date): ?SecurityPrice
   {
       // Code to interact with the first API
   }
}

<?php

namespace Mauro\Strategy;

use DateTimeInterface;

class SecondAPIPriceFetcher implements SecurityPriceFetcher
{

   function fetch(Security $security, DateTimeInterface $date): ?SecurityPrice
   {
       // Code to interact with the second API
   }
}

<?php

namespace Mauro\Strategy;

use DateTimeInterface;

class ThirdAPIPriceFetcher implements SecurityPriceFetcher
{

   function fetch(Security $security, DateTimeInterface $date): ?SecurityPrice
   {
       // Code to interact with the third API
   }
}

You could also create these starting with a base abstract class instead of an interface; it doesn’t really matter that much. The important thing is they are basically performing the same action, though with different approaches.

The client code

With these strategies at hand, the orchestration function becomes much cleaner and generic:

<?php

use Mauro\Strategy\Security;
use Mauro\Strategy\SecurityPrice;

function getPriceFor(Security $security, DateTimeInterface $date, array $fetchers): ?SecurityPrice
{
   foreach ($fetchers as $fetcher) {
       $price = $fetcher->fetch($security, $date);
       if ($price !== null) {
          
           return $price;
       }
   }

   return null;
}

And the calling site is explicit and readable:

echo getPriceFor(
   new Bond("AA11232H", "Some government-issued security"),
   new DateTimeImmutable(),
   [
       new FirstAPIPriceFetcher(),
       new SecondAPIPriceFetcher(),
       new ThirdAPIPriceFetcher(),
   ]
)->value;

And then, when a new API becomes available, all you have to do is:

1. Create the new class

<?php

namespace Mauro\Strategy;

use DateTimeInterface;

class FourthAPIPriceFetcher implements SecurityPriceFetcher
{

   function fetch(Security $security, DateTimeInterface $date): ?SecurityPrice
   {
       // Code to interact with the third API
   }
}

2. Add a new instance of such a class to the array passed to the function getPriceFor

echo getPriceFor(
   new Bond("AA11232H", "Some government-issued security"),
   new DateTimeImmutable(),
   [
       new FirstAPIPriceFetcher(),
       new SecondAPIPriceFetcher(),
       new ThirdAPIPriceFetcher(),
       new FourthAPIPriceFetcher(),
   ]
)->value;

Also, should you realize that the calling order is not ideal, it’s just a matter of reorganizing the array and voilà:

echo getPriceFor(
   new Bond("AA11232H", "Some government-issued security"),
   new DateTimeImmutable(),
   [
       new ThirdAPIPriceFetcher(),
       new FirstAPIPriceFetcher(),
       new FourthAPIPriceFetcher(),
       new SecondAPIPriceFetcher(),
   ]
)->value;

Now things look more promising, don’t they? And, on top of that, you get to write separate tests for each strategy and the orchestration function:

<?php

use Mauro\Strategy\MutualFund;
use Mauro\Strategy\SecurityPrice;
use Mauro\Strategy\SecurityPriceFetcher;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;

require_once '../vendor/autoload.php';
require_once '../pricer.php';

class PricerTest extends TestCase
{
   #[Test]
   public function shouldReturnTheFirstPositiveAnswer(): void
   {
       $firstFetcher = $this->createMock(SecurityPriceFetcher::class );
       $secondFetcher = $this->createMock(SecurityPriceFetcher::class );
       $security = new MutualFund("123456", "Mutual fund 1");
       $date = new DateTimeImmutable();
       $expectedPrice = new SecurityPrice($security, $date, 1);

       $firstFetcher
           ->method("fetch")
           ->willReturn($expectedPrice);
       $secondFetcher
           ->expects($this->never())
           ->method("fetch");

       $actualPrice = getPriceFor(
           $security,
           $date,
           [
               $firstFetcher,
               $secondFetcher,
           ]
       );

       $this->assertEquals($expectedPrice, $actualPrice);
   }
}

The Decorator Pattern

Allow me to illustrate it with another example around the same domain. Let’s say that we want to keep a log of every API call we make. We might be tempted to go back to our getPriceFor function and simply add a little line like:

<?php

use Mauro\Strategy\Security;
use Mauro\Strategy\SecurityPrice;

function getPriceFor(Security $security, DateTimeInterface $date, array $fetchers): ?SecurityPrice
{
   global $logger;

   foreach ($fetchers as $fetcher) {
       $logger->log("Trying ".get_class($fetcher));
       $price = $fetcher->fetch($security, $date);
       if ($price !== null) {
          
           return $price;
       }
   }

   return null;
}

Looks innocent, doesn’t it? It’s just a simple line, what harm could it do? Probably nothing, but we’re changing a perfectly working piece of code for no good reason.

To make my next point more explicit, let’s assume we’re only interested in logging calls to the first and second APIs, but not the third. Suddenly, things got weird. Are you going to add an if on top of the call to the logger? That doesn’t seem like a good idea. Then again, why should you have to modify code that is performing its duty? A better approach is to put together a small Decorator around the classes that deal with the APIs you’re interested in logging.

The logging Decorator

It all starts with this definition:

<?php

use Mauro\Strategy\Security;
use Mauro\Strategy\SecurityPriceFetcher;

class LoggedPriceFetcher implements SecurityPriceFetcher {
   private SecurityPriceFetcher $wrapped;
  
   private Logger $logger;
  
   public function __construct(SecurityPriceFetcher $wrapped, Logger $logger) {
       $this->wrapped = $wrapped;
   }
  
   public function fetch(Security $security, DateTimeInterface $date): ?SecurityPrice
   {
       $this->logger->log("Trying ".__CLASS__);
      
       return $this->wrapped->fetch($security, $date);
   }
}

We simply create a wrapper around the actual worker class and add the new functionality around the original one. In this case, we’re logging before making the call, but in the same fashion, we might have done it afterwards.

Now it’s up to the caller to use the regular SecurityPriceFetcher or the decorated one.

$logger = new Logger();
echo getPriceFor(
   new Stock("I12321312W", "GoogStock"),
   new DateTimeImmutable()->sub(new DateInterval("P1D")),
   [
       new FirstAPIPriceFetcher(),
       new LoggedPriceFetcher(new SecondAPIPriceFetcher(), $logger),
       new ThirdAPIPriceFetcher(),
   ]
)->value;

The most important detail here is that, for this to work, the decorator must implement the same interface as the decorated class. That is the “trick” to have the client code (getPriceFor in our case) completely ignorant of the fact that it’s talking to an augmented version of the object it expects.

Perhaps logging doesn’t look like such a big deal to you. Let me try to convince you with a more nuanced example. Let’s say that some APIs measure their prices in Euros while others do it in USD, and your application uses Euros all around. The same principle applies. You could implement this conversion logic in every PriceFetcher or even at the getPriceFor level, but that would be a waste, to say the least, and a big problem if things get out of hand. Think about how you’ll keep track of different exchange rates if they’re scattered all over the place.

Now that you know how your Decorators can save the day (and let’s admit it, make you look cool), why not use one of those bad boys? The gist is pretty similar. We start with:

<?php

namespace Mauro\Strategy;

use DateTimeInterface;

class ConvertToEuroPriceFetcher implements SecurityPriceFetcher
{
   private SecurityPriceFetcher $wrapped;
   private USDToEURConverter $converter;

   public function __construct(SecurityPriceFetcher $wrapped, USDToEURConverter $converter)
   {
       $this->wrapped = $wrapped;
       $this->converter = $converter;
   }

   public function fetch(Security $security, DateTimeInterface $date): ?SecurityPrice
   {
       return $this
           ->converter
           ->convert($this->wrapped->fetch($security, $date));
   }
}

And then we can use it as we see fit:

$logger = new Logger();
$converter = new USDToEURConverter();
echo getPriceFor(
   new Stock("I12321312W", "GoogStock"),
   new DateTimeImmutable()->sub(new DateInterval("P1D")),
   [
       new FirstAPIPriceFetcher(),
       new LoggedPriceFetcher(
           new SecondAPIPriceFetcher(),
           $logger
       ),       
       new ConvertToEuroPriceFetcher(
           new ThirdAPIPriceFetcher(),
           $converter
       ),
   ]
)->value;

Stacking Decorators

A really cool thing about Decorators is that you can combine them however you want. For instance, you may want to log the calls to the API that need currency conversion. You don’t need to go too far to achieve such behavior. Since the Decorator exposes the same interface as the worker, there’s no reason why you can’t use a decorated object as the input to another decorator:

$logger = new Logger();
$converter = new USDToEURConverter();
echo getPriceFor(
   new Stock("I12321312W", "GoogStock"),
   new DateTimeImmutable()->sub(new DateInterval("P1D")),
   [
       new FirstAPIPriceFetcher(),
       new LoggedPriceFetcher(
           new SecondAPIPriceFetcher(),
           $logger
       ),
       new ConvertToEuroPriceFetcher(
           new LoggedPriceFetcher(
               new ThirdAPIPriceFetcher(),
               $logger
           ),
           $converter
       ),
   ]
)->value;

As you can see, in the case of the third API, I am using both decorators. With the second API, I’m only using one of them. That’s the beauty of this pattern: you can use any conjunction you need to achieve your goals.

Now, there’s a subtle issue I want to clarify. In this example, the decoration order doesn’t change anything. If I wrote:

$logger = new Logger();
$converter = new USDToEURConverter();
echo getPriceFor(
   new Stock("I12321312W", "GoogStock"),
   new DateTimeImmutable()->sub(new DateInterval("P1D")),
   [
       new FirstAPIPriceFetcher(),
       new LoggedPriceFetcher(
           new SecondAPIPriceFetcher(),
           $logger
       ),
       new ConvertToEuroPriceFetcher(
           new LoggedPriceFetcher(
               new ThirdAPIPriceFetcher(),
               $logger
           ),
           $converter
       ),
   ]
)->value;

The end result would look exactly the same as the former example, but that’s a mere coincidence. In this particular case, the decorations deal with completely different aspects of the problem, so there’s no interference. In many other scenarios where this is not the case, you want to be very mindful of the order in which your decorators are applied.

Consider a hypothetical CachedPriceFetcher Decorator. Should you log before or after checking the cache? That depends entirely on what you’re trying to observe. The ordering becomes a meaningful design decision rather than an afterthought.

What about Traits?

Another point I’d like to explore in this article is the use of Traits instead of Decorators. After all, they seem to offer a pretty similar advantage, don’t they? With Traits, you can have your objects implement extra functionality without re-coding it over and over. So, should you prefer them to old-fashioned Decorators? I’m afraid the answer is most likely “No.”

Here’s why. Traits are, at their core, a form of horizontal inheritance. When a class uses a Trait, the link is static—sealed at compile time. You can’t choose at runtime to give an instance of FirstAPIPriceFetcher the logging trait but not the conversion trait, and another instance the other way around.

What I mean by this is that, unlike Decorators, you can’t dynamically combine them however you might need. Once a class uses a Trait, that’s it; their link is sealed for good. You can always resort to some obscure reflection tricks to break it, but if you have to go that way, you should be having second thoughts already.

Let me show you an example to make the point clearer. Say you implement the logging mechanism as a trait:

<?php

namespace Mauro\Strategy;

use Logger;

trait LoggableFetcherTrait
{
   private Logger $logger;

   public function setLogger(Logger $logger): void
   {
       $this->logger = $logger;
   }

   private function logAttempt(string $className): void
   {
       if (isset($this->logger)) {
           $this->logger->log("Trying " . $className);
       }
   }
}

That would imply changing the Strategies to something like:

<?php

use Mauro\Strategy\LoggableFetcherTrait;
use Mauro\Strategy\Security;
use Mauro\Strategy\SecurityPrice;
use Mauro\Strategy\SecurityPriceFetcher;

class FirstAPIPriceFetcher implements SecurityPriceFetcher
{
   use LoggableFetcherTrait;

   public function fetch(Security $security, DateTimeInterface $date): ?SecurityPrice
   {
       $this->logAttempt(__CLASS__);

       // Do the magic
       return $price;
   }
}

This takes away the whole point of decorators, doesn’t it? Now you need to explicitly call the log method. More so, when the time comes to combine these kinds of traversal features, the headaches grow exponentially as your classes need to go out of their way to keep track of responsibilities they didn’t have before.

Also, their all-or-nothing nature is the definite argument against Traits in this scenario. When you use a Trait, you are saying that all instances of the class will exhibit a specific behavior. With a Decorator, you can decide that on a case-by-case basis, giving you way more flexibility.

Now, I don’t want you to end up with the idea that Traits are somehow evil or that I have something personal against them. As with any other tool, they have their use cases, such as timestamp management, soft-deletes, or serialisation helpers. It’s just that they are not a replacement for Decorators.

As for the testing side, the same principles I discussed for the Strategy case apply here. Unlike the monolithic function version, there’s no need for real (expensive and unpredictable) API calls, elaborate mocks, or reflection gymnastics. Each test stays focused and clean.

The SOLID connection

Though I didn’t mention it explicitly until now, it’s worth noting that, by leveraging these patterns, you’re complying with both Single Responsibility and Open/Close principles (a very important part of SOLID).

SRP

Both the Strategy and the Decorator have a very clear function, one that can be defined independently of the system surrounding them and, most importantly, changed without affecting it.

Should the protocol change for any of the APIs, the update would be circumscribed to a single class, which, once properly tested, can safely replace the pre-existing implementation.

The same is true for the Decorators. Since there is such a clean separation of concerns, moving to a different logging mechanism doesn’t generate any impact on any part of the application whatsoever.

OCP

At this point, it should be clear that new Strategies and Decorators can be added at any time and without significant effort, which effectively makes the application easily extensible to adapt to the constantly evolving business environment. Of course, this is by no means a coincidence. The patterns were designed with these goals in mind.

When not to use these patterns

As with any other tool in your box, there are times when the best course of action is to leave them out of sight, and, while the patterns I’ve shown you through this article are really valuable, there are times when they take more than they provide.

In general, I’d recommend you stay away from Strategy when you only have one implementation and no foreseeable need for others. I want to stress the last part of that sentence: don’t let yourself get trapped in “but what if…?” thoughts. You’ll fix problems when they’re actually there; anything before that is just speculation.

Also, skip the Decorator when the additional behavior is always required. If you never create an undecorated version, there’s no point in the wrapper.

Summary

Here’s what you learned in this article:

  1. Both Strategy and Decorator are good alternatives to nested if statements.
  2. The Strategy pattern is useful when there are many ways to achieve the same outcome, and you want to use the most appropriate one for the context.
  3. The Decorator pattern is useful when there are complementary features you want to add to a core functionality.
  4. The Strategy pattern and the Decorator pattern are not replacements for each other but rather complements (they make a powerful team).
  5. The order in which you apply your Decorators can have a significant impact on the end result.
  6. Both patterns make your code dramatically easier to test.
  7. Traits offer static composition. Decorators offer dynamic composition.

Here’s a little cheat sheet in case you’re in doubt about which option to use:

Comparison table showing how Strategy, Decorator, and Trait differ in PHP by nature, decision time, purpose, and logical relation.

Final Thoughts

The next time you are tempted to add a boolean parameter like $withLogging or $convertToEuro to your function, stop for a second and ask yourself, ”Am I choosing a path, or am I adding a layer?” Either way, chances are a Strategy or a Decorator will be a better option in the long run. And you already know how to make the choice between them.

Your future self—the one who will have to maintain this code six months from now when the APIs change and the business rules double—will thank you for choosing the elegance of composition over the immediate convenience of an if statement.

The post Strategy vs Decorator in PHP: Choosing the Right Design Pattern appeared first on International PHP Conference.

]]>
The First AI Coding Agent Built Entirely in PHP https://phpconference.com/blog/maestro-ai-coding-agent-php/ Wed, 27 May 2026 09:34:11 +0000 https://phpconference.com/?p=209911 Maestro, built on the Neuron AI framework, proves that fully autonomous AI coding agents can be developed in PHP. This article provides a walkthrough for Maestro and Neuron AI, showing how tool approval, event-driven rendering pipelines, multi-provider abstraction and MCP integration are possible.

The post The First AI Coding Agent Built Entirely in PHP appeared first on International PHP Conference.

]]>
For a long time, the implicit message from the AI tooling industry has been: if you want to build agents, learn Python. Frameworks, tutorials, and conference talks all pointed in the same direction. PHP developers who wanted to experiment with autonomous systems had two options: switch stacks or stitch something together from raw API calls and hope it holds.

IPC NEWSLETTER

All news about PHP and web development

[mc4wp-simple-turnstile]

 

That’s the gap Neuron AI was built to close. And now, with Neuron v3 introducing a workflow-first architecture, I wanted to prove the point in the most direct way possible: build something that the ecosystem assumes can only be done in another language. That’s how Maestro was born—the first coding agent built entirely in PHP.

Maestro CLI agent promotional graphic powered by the Neuron AI framework

Fig. 1: Maestro CLI agent

 

Maestro CLI help screen listing available inline commands such as discover, extensions, help, init, and provider

Fig. 2: Maestro help command

 

Maestro CLI tool approval prompt showing requested file and shell operations with allow or reject options

Fig. 3: Maestro too approval

 

Maestro CLI editing a PHP summarization file and showing highlighted code changes in the terminal

Fig. 4: Maestro file edit

What is Neuron?

Neuron is a PHP framework for developing agentic applications. By handling the heavy lifting of orchestration, data loading, and debugging, Neuron clears the path for you to focus on the creative soul of your project. From the first line of code to a fully orchestrated multi-agent system, you have the freedom to build AI entities that think and act exactly how you envision them.

Neuron provides tools for the entire agentic application development lifecycle, from LLM interfaces, to data loading, to multi-agent orchestration, to monitoring and debugging. In addition, it provides tutorials and other educational content to help you get started using AI Agents in your projects.

YOU LOVE PHP?

Explore the PHP Core Track

 

Neuron’s architecture prioritizes the fundamentals that experienced engineers expect from production-grade software.

Neuron AI architecture diagram showing Agent, RAG, Workflow, MCP Connector, AI Provider, Data Loaders, Vector Store, and Embeddings

Fig. 5: Neuron architecture

Strong Typing System

The framework leverages PHP 8’s mature type system throughout its codebase, with every method signature, property, and return value explicitly typed. The entire framework passes PHPStan 100% type coverage.

IDE Friendly

The strongly-typed approach means your IDE can provide accurate autocompletion for agent configurations, tool parameters, and response handling. Method signatures include detailed PHPDoc annotations that provide context beyond type hints when needed, explaining parameter expectations and return value structures.

This foundation allows faster debugging cycles, easy integration patterns with frameworks like Symfony or Laravel. We assume you’re building systems that need to be maintained, extended, and understood by teams rather than individual experiments.

Carefully Crafted Architecture

Neuron uses standard PSR interfaces where appropriate and maintains minimal external dependencies, avoiding conflicts across different PHP environments and framework versions. This design choice prevents the common problem where introducing a new library increases the risks of getting stuck due to incompatible versions of dependencies.

For teams working across multiple projects, this approach provides consistency. The same Neuron patterns and implementations work regardless of whether you’re building a new microservice in pure PHP, extending a WordPress site, or adding features to an enterprise Symfony and Laravel application. Knowledge transfer between projects becomes seamless, and developers can leverage their Neuron expertise across their entire PHP portfolio.

 

Community Driven

These design principles create a unified ecosystem for AI development across all PHP communities. Rather than fragmenting innovation across framework-specific solutions, Neuron enables collaboration between Laravel developers, Symfony contributors, WordPress plugin authors, and custom framework maintainers. When improvements are made to Neuron’s core capabilities, they benefit every PHP developer.

Neuron’s universal approach attracts contributors from across the PHP ecosystem, leading to more robust implementations, broader testing across different environments, and faster development of new features. This collaborative approach also means better support for newcomers, as experienced developers from various PHP backgrounds can provide guidance and assistance.

What a Coding Agent Actually Does

Before looking at the code, it’s worth being precise about what a coding agent is, because the term gets stretched a lot. Maestro isn’t a code completion tool. It’s an autonomous agent that runs in your terminal, reads your project files, reasons about your codebase, and proposes changes. It operates in a loop: you give it a task, it decides which tools to call (read a file, search for patterns, write changes), executes them in sequence, and reports back. The key word is proposes—before touching your filesystem, it asks for your approval.

That last part is not simply a nice to have feature. Any agent with write access to your codebase that doesn’t pause for confirmation is a liability. The tool approval mechanism in Maestro is one of the things I’m most satisfied with, and it maps directly to a feature that Neuron v3 introduced as a first-class concept: human-in-the-loop workflow interruption.

Coding Agent Architecture

The repository structure reflects a clear separation of concerns. The entry point is bin/maestro, which bootstraps a Symfony Console command. From there, everything fans out cleanly:

bin/maestro (Symfony Console Application)
    └─ MaestroCommand (main command)
        ├─ Settings (.maestro/settings.json)
        ├─ EventBus\EventDispatcher (PSR-14 compatible)
        ├─ CliOutputListener (subscribes to events)
        └─ AgentOrchestrator (drives chat loop)
            └─ CodingAgent (extends NeuronAI Agent)
                ├─ ProviderFactory → AIProvider
                ├─ FileSystemToolkit (read-only FS tools)
                └─ McpConnector[] (optional MCP servers)

The CodingAgent class extends Neuron’s Agent base and adds a tool approval middleware. This is the piece that intercepts execution before any filesystem write, fires a ToolApprovalRequestedEvent, and waits. The AgentOrchestrator catches the workflow interrupt thrown by the middleware, presents the approval prompt to the user via the CLI, and resumes or aborts execution based on the response.

This pattern—interrupt, present, resume—would have been painful to implement without a workflow-oriented framework underneath. With Neuron v3, it’s the natural way to build it.

Inline Commands

The Maestro CLI implements an elegant inline command system that allows users to execute special commands directly from the interactive chat interface without exiting the main loop. You can type “slash commands” (e.g., /help, /init) that are handled by a plugin-like registry system.

The architecture relies on three core components: a clean InlineCommand interface that defines the contract for all commands, a central registry that manages command registration and lookup while preventing duplicate names, and an adapter class that enables wrapping existing Symfony Console commands as inline commands without rewriting their logic.

What makes this system particularly powerful is its extensibility through the adapter pattern. Rather than duplicating code between standalone console commands and their inline counterparts, the InlineCommandAdapter class can wrap any existing Symfony command, automatically extracting the command name and description and handling the conversion between inline argument strings and Symfony’s input format. This design choice means commands like /init can reuse the full logic of the InitCommand class, including its interactive prompts and validation, while presenting a simplified interface within the chat session.

The registry pattern naturally supports command discovery through the built-in /help command, which dynamically lists all registered commands with their descriptions, helping the user understand the available CLI capabilities. Adding a new inline command is as simple as implementing the interface and registering it in the constructor.

class MaestroCommand extends Command
{
    protected InlineCommandRegistry $registry;

    public function __construct(?string $name = null, ?callable $code = null)
    {
        parent::__construct($name, $code);

        // Initialize inline commands
        $this->registry = new InlineCommandRegistry();
        $this->registry->register(new InitInlineCommand());
        $this->registry->register(new HelpInlineCommand($this->registry));
    }

    ...
}

Getting Started

Install as a global composer tool:

composer global require neuron-core/maestro

Make sure Composer’s global bin directory is in your system

PATH:

export PATH="$HOME/.config/composer/vendor/bin:$PATH"

Configuration lives in .maestro/settings.json at the root of your project. Run the init command to start the interactive guide:

cd /pth/to-project

maestro init

At minimum, you need a provider and an API key:

{
	"default": "anthropic",
    "providers": {
        "anthropic": {
			"api_key": "sk-ant-your-key-here",
			"model": "claude-sonnet-4-20250514"
		}
    }
}

Maestro supports Anthropic, OpenAI, Gemini, Cohere, Mistral, Ollama, Grok, and Deepseek out of the box—all routed through a ProviderFactory that maps the default field to the corresponding Neuron AI provider instance. If you want to run everything locally without sending data to an external API, point it at an Ollama instance:

{
	"default": "ollama",
    "provider": {
        "ollama": {
			"base_url": "http://localhost:11434",
			"model": "llama2"
		}
    }
}

Giving the Agent Context About Your Project

By default Maestro tries to load the Agents.md file from the project directory. Alternatively, you can point Maestro at a different markdown file in your repo that describes your project’s architecture, coding standards, and any conventions the agent should follow. You can configure the context_file property in the settings file with the path to the file.

{
	"default": "ollama",
    "providers": { ... },
    "context_file": "CLAUDE.md"
}

The agent appends the content of that file to its system instructions before the conversation starts. This is a simple mechanism, but it makes a real difference in practice. An agent that knows your project uses PSR-12, that controllers shouldn’t contain business logic, and that you prefer dependency injection over service locators will produce more relevant suggestions from the first message.

IPC NEWSLETTER

All news about PHP and web development

[mc4wp-simple-turnstile]

 

The Tool Approval Flow

When the agent wants to modify a file, execution doesn’t just proceed. It stops, and you see something like this:

The agent wants to write changes to src/Service/UserService.php

[1] Allow once
[2] Allow for session
[3] Always allow
[4] Deny

“Allow for session” is the option I use most in practice. It means I approve write operations on a given file type or tool once per session, without having to confirm each individual change. “Always allow” persists the preference to .maestro/settings.json under an allowed_tools key, so future sessions skip the prompt entirely for that operation.

This granularity matters. You probably want to approve the first few changes in an unfamiliar session to build confidence, then let the agent run more freely once it’s demonstrated it understands what you’re asking.

This is one of the most interesting features provided by the Neuron AI framework, thanks to the Workflow architecture. Neuron Workflow supports execution interruption, so you can create a fully customizable human-in-the-loop experience. The agent will stop its execution, waiting to be resumed exactly from where it left off. Learn more on the official documentation.

The Event System

Maestro uses a lightweight PSR-14-compatible event dispatcher with three events: AgentThinkingEvent (fires before each AI call), AgentResponseEvent (fires when the model returns), and ToolApprovalRequestedEvent (fires when a tool needs approval). The CliOutputListener subscribes to these and handles all terminal rendering.

This design keeps the agent logic clean. The CodingAgent doesn’t know anything about how output is displayed—it just fires events. If you wanted to build a web interface on top of the same agent, you’d swap out the listener and leave everything else untouched.

MCP Integration

For teams that want to extend the agent’s capabilities beyond filesystem operations, Maestro supports Model Context Protocol servers in the configuration:

{
    "mcp_servers": {
        "inspector": {
            "url": "https://app.inspector.dev/mcp?app=<APP_ID>",
            "args": "INSPECTOR_API_TOKEN"
        }
    }
}

Each entry in mcp_servers spins up a subprocess and connects it to the agent as an additional tool source. The agent can then check the application monitoring data on Inspector , search the web, or access any MCP-compatible service alongside its native filesystem tools.

 

What This Demonstrates

Maestro is a working proof that the patterns the rest of the industry has been building in Python and TypeScript are fully expressible in PHP now. The workflow architecture that makes tool approval possible, the event-driven rendering pipeline, the multi-provider abstraction, the MCP integration, none of this required stepping outside the PHP ecosystem.

The framework doing the heavy lifting here is Neuron AI, specifically the workflow architecture introduced in v3. Without the ability to interrupt execution mid-agent-loop and resume it based on user input, the tool approval system would require significantly more scaffolding to build and maintain.

I’m really looking forward to hearing your feedback, experiments, and ideas on how to develop this new chapter of AI ​​in the PHP space.

If you want to explore the code, the repository is at github.com/neuron-core/maestro. The Neuron AI documentation lives at docs.neuron-ai.dev. Questions, issues, and pull requests are open.

The post The First AI Coding Agent Built Entirely in PHP appeared first on International PHP Conference.

]]>
Better HTML Parsing in PHP: Modern Techniques and Tools https://phpconference.com/blog/better-html-parsing-php/ Mon, 13 Apr 2026 13:08:54 +0000 https://phpconference.com/?p=209849 HTML parsing has long been a pain point in PHP, especially when dealing with real-world, imperfect markup. This article shows how recent PHP releases change that story, introducing a modern HTML parser that accurately handles today’s web content, robust CSS selector support for element retrieval, and new DOM classes that align more closely with the official DOM specification. We will walk through practical examples in web scraping, content extraction, and HTML transformation, and you will learn how to migrate existing parsing code to benefit from these native improvements.

The post Better HTML Parsing in PHP: Modern Techniques and Tools appeared first on International PHP Conference.

]]>
What is HTML parsing? It’s essentially a process to turn an HTML string into an object that we can work with more easily. It makes the elements inside easier to access. Your browser does this every time you load a web page.

Illustration of an HTML snippet being converted into a Document Object Model tree. The code on the left contains html, head, title, body, h1, and p elements; the diagram on the right shows those nodes as a hierarchy with text nodes like “Sample Page,” “Hello World,” and “This is a paragraph.”

The HTML is parsed into a DOM (Document Object Model) object. When you use your browser’s developer tools to inspect an element on a webpage, you are relying on the DOM. We can also turn a DOM object back into an HTML string if we need to save it somewhere.

Illustration of a Document Object Model tree on the left being converted back into HTML markup on the right. The tree contains html, head, title, body, h1, and p nodes, and the output shows the equivalent HTML source.

For many developers, parsing HTML has long been a source of frustration. While PHP’s DOMDocument class has allowed us to do this, its reliance on the libxml2 library meant it could not handle the kind of HTML that browsers today have to deal with. It tripped up on certain inline JavaScript, leaking the code into other parts of the resulting DOM, and often required unreliable hacks to produce a decent result.

Note: There has been an open issue to add HTML5 support in libxml2 for a while. There also appears to have been some progress in the last year. At the same time, however, the maintainer of the library, Nick Wellnhofer, has announced he is stepping down. So at the time of writing, it’s unclear what the future holds.

With the release of PHP 8.4 in November 2024, that era is finally over.

IPC NEWSLETTER

All news about PHP and web development

[mc4wp-simple-turnstile]

 

PHP 8.4 introduces a massive overhaul to the DOM extension, featuring a new, standards-compliant HTML5 parser [2], native CSS selector support, and a modernized set of DOM classes. These additions make PHP a viable, high-performance choice for web scraping, content extraction, and HTML transformation tasks that previously required slower third-party libraries based on older specs.

Note: From this point on, we will simply use “HTML” rather than “HTML5.” The current standard is called the “HTML Living Standard,” maintained by WHATWG.

I’m slowly updating the PHP Readability library, used for article extraction, to use the new DOM API in PHP. In this article, we’ll explore what’s new, and walk through practical examples of how to migrate your own code.

Technical Foundation

The secret sauce behind PHP 8.4’s parsing capabilities is Lexbor, a high-performance HTML parser written in C by Alexander Borisov. Unlike libxml, Lexbor is based on the WHATWG HTML Living Standard. This means it parses HTML more like a modern web browser does – handling unclosed tags and quirky markup.

Because Lexbor is a C library, it is incredibly fast. It eliminates the overhead of userland parsers (like the popular html5-php library) and often outperforms the libxml parser while providing significantly better accuracy. It is included in the DOM extension by default, requiring no extra configuration or external dependencies.

The integration of Lexbor into PHP, along with the other DOM changes described in this article, came about thanks to Niels Dossche. Niels is a PHP core contributor and researcher at Ghent University.

For backward compatibility, he has ensured that the changes do not affect existing code, by providing the new DOM classes under the Dom namespace.

The Old Way: Parsing with libxml

Three-column comparison of LIBXML, HTML5-PHP, and LEXBOR. LIBXML is described as a fast C-based parser from 1999 based on HTML4 with partial HTML5 support; HTML5-PHP as a PHP library from 2013 based on an older W3C HTML5 standard; and LEXBOR as a fast C-based parser from 2018 based on the newer WHATWG standard, available by default in most PHP 8.4 installations.

To really appreciate the upgrade, let’s first look at the old way. The native PHP way has been to rely on the DOMDocument class, which uses libxml under the hood. While libxml is excellent for XML, it predates the current HTML Living Standard and has struggled with modern markup for a long time.

Consider the following HTML document. It contains two paragraphs and a script element in between them.

<!DOCTYPE html>
<title>Valid HTML Document</title>
<p>Paragraph 1</p>
<script>console.log("</html>Console log text");</script>
<p>Paragraph 2</p>

This is valid HTML. A browser knows that </ html> is inside a string in a script element and should be treated as text. However, DOMDocument gets confused.

Note: Many opening and closing tags can be omitted, e.g. < html>, < head>, < body>. The parser will infer them automatically. I want to stress that all the HTML I’m presenting in this article is valid, conforming to the current HTML standard. While the HTML parser spec goes into details about how to handle invalid, non-conforming HTML, we’re setting a lower bar here when comparing parsing results, using HTML you’ll encounter in the wild.

$dom = new DOMDocument();
$dom->loadHTML($html);
$paragraphs = $dom->getElementsByTagName('p');
echo "Found {$paragraphs->length} paragraphs.";
// Output: Found 3 paragraphs.

Why 3 paragraphs, and not 2? Because the DOMDocument sees the </ html> inside the script, assumes the document has ended, and then treats the remaining text (Console log text”);) and the second paragraph as new content outside the body, mangling the structure entirely. If you serialize this back to HTML, you get a broken mess:

<html>
<body>
<p>Paragraph 1</p>
<script>console.log("</script>
</body>
</html>
<html>
<p>Console log text");</p>
<p>Paragraph 2</p>
</html>

Two-column table mapping CSS selectors to XPath 1.0 expressions, with examples such as div.content, article#main, [src*="avatar"], article p, and article > p, plus two emoji-marked rows showing more awkward XPath equivalents for matching text and links.

The Workarounds

So what have developers done about this? Historically, there have been two main approaches:

  1. Use a better parser: The popular library html5-php implements an older W3C HTML5 parsing spec in pure PHP. It’s an improvement over libxml, but it hasn’t kept up with the latest spec (WHATWG’s HTML Living Standard). Additionally, being a PHP implementation means it is slower than C-based parsers like libxml.
  2. Clean the HTML before parsing: Some developers used the Tidy extension to repair and clean markup before parsing it.

With the new parser, neither of these should be needed now.

Note: Tidy re-writes the HTML in a way that older parsers can sometimes parse better. But not always. I’ve encountered HTML which either Tidy itself struggles with, or in which Tidy’s output doesn’t produce better results when passed to PHP’s DOMDocument.

EVERYTHING IS CONNECTED TO THE INTERNET

Explore the Web Development Track

 

The New Way: Parsing with Lexbor

With the release of PHP 8.4, PHP introduces the new Dom\HTMLDocument class. When you parse HTML using this class, you are using Lexbor, PHP’s new HTML parser.

Here is how we parse the same document with Lexbor, using the new class:

$dom = Dom\HTMLDocument::createFromString($html);
$paragraphs = $dom->getElementsByTagName('p');
echo "Found {$paragraphs->length} paragraphs.";
// Output: Found 2 paragraphs.

The new parser correctly identifies the context of the script tag and preserves the document structure.

Diagram showing the same source HTML processed with the new PHP 8.4+ Dom\HTMLDocument::createFromString() parser. It correctly reports 2 paragraph elements and serializes the document without corrupting the script content, demonstrating proper HTML5 parsing.

Performance and Standards

Comparing PHP’s new parser with the html5-php library, I found the native PHP implementation is approximately 3.6x faster on average for typical news and blog pages. For larger, more complex documents, users should find it even faster.

More importantly, it adheres to a more recent HTML standard. HTML today is a “Living Standard” maintained by the WHATWG, meaning it has no version numbers and changes over time. Both libxml and html5-php are based on older standards. Lexbor, PHP’s new parser, is based on the more recent WHATWG standard, so is closer to modern browser parsing.

New DOM Classes

To support the new features without breaking decades of existing code, PHP 8.4 introduces a new set of DOM classes under the DOM namespace. These live alongside the existing global classes (like DOMDocument), allowing both APIs to coexist in the same application.

Here is how the key classes map to the new namespace:

  • DOMDocument → Dom\HTMLDocument (there is also Dom\XMLDocument for XML)
  • DOMElement → Dom\Element
  • DOMNode → Dom\Node
  • DOMText → Dom\Text
  • DOMAttr → Dom\Attr
  • DOMXPath → Dom\XPath

Why create new classes instead of fixing the old ones? Niels found that attempts to fix bugs in the old DOM classes caused too many issues because many of us have had to rely on the incorrect behavior. By creating a fresh namespace, the new classes can adhere strictly to the spec while the old classes remain untouched for legacy code.

Migration and Interoperability

Thankfully, migration doesn’t have to be all-or-nothing. You can mix both APIs in the same codebase. And if you need to, you can import legacy DOMNode objects with the importLegacyNode method:

$oldDom = new DOMDocument();
$oldDom->loadHTML('<p>Old node</p>');
$oldElement = $oldDom->getElementsByTagName('p')->item(0);
echo "Old element class: " . $oldElement::class . PHP_EOL;
// Output: Old element class: DOMElement

$newDom = Dom\HTMLDocument::createFromString('<!DOCTYPE html>');
$newElement = $newDom->importLegacyNode($oldElement, deep: true);
echo "New element class: " . $newElement::class . PHP_EOL;
// Output: New element class: Dom\Element
$newDom->body->append($newElement);
// Serialise to HTML
echo $newDom->body->innerHTML;
// Output: <p>Old node</p>

DOM Properties and innerHTML

The new API introduces several quality-of-life improvements that reduce boilerplate code.

IPC NEWSLETTER

All news about PHP and web development

[mc4wp-simple-turnstile]

 

Top-Level Properties

You no longer need to traverse the tree to find the < body > or < head > tags. They are now exposed as first-class properties on the document object:

$html = '<!DOCTYPE html>
<title>Old title</title>
<h1>Hello</h1>';

$dom = Dom\HTMLDocument::createFromString($html);
// Access convenience elements directly
echo $dom->head::class . PHP_EOL; // Dom\HTMLElement
echo $dom->body::class . PHP_EOL; // Dom\HTMLElement
// Read or write the title directly
echo $dom->title . PHP_EOL;
// Output: Old title
$dom->title = "New Title";
echo $dom->head->innerHTML;
// Output: <title>New Title</title>

innerHTML Support

What about getting the HTML content of an element? We now have native innerHTML support.

It works just like JavaScript:

$div = $dom->querySelector('div');

// Read content
echo $div->innerHTML;

// Write content (automatically parses the string into nodes)
$div->innerHTML = '<p>Replaced content</p>';

Note: While_ innerHTML is supported, _outerHTML is not yet available in this release.

CSS Selector Support

Perhaps the most exciting feature for web scraping is native support for CSS selectors. You can finally say goodbye to getElementsByTagName and the complexity of DOMXPath.

The new classes implement querySelector and querySelectorAll, behaving identically to their JavaScript counterparts:

  • querySelector($selectors) — Returns the first descendant element that matches the CSS selectors
  • querySelectorAll($selectors) — Returns a NodeList containing all descendant elements that match the CSS selectors
$dom = Dom\HTMLDocument::createFromString($html);

// Find the first matching element
$article = $dom->querySelector('article.main');

// Find all matching elements (returns a NodeList)
$links = $dom->querySelectorAll('ul.nav > li > a');

Powerful Selectors

You aren’t limited to basic class or ID selectors. You have access to modern, complex CSS selectors:

Multiple Element Types: Select headers and paragraphs in one go:

$elements = $dom->querySelectorAll('h1, h2, h3, p');

Combinators (:is, :where): Simplify complex grouping:

// Select paragraphs and main headings that are direct children of article
$elements = $dom->querySelectorAll('article > :is(p, h1, h2)');
// Same as
// $elements = $dom->querySelectorAll('article > p, article > h1, article > h2');

State Selectors (:empty, :not):

// Find all paragraphs that are NOT empty
$elements = $dom->querySelectorAll('p:not(:empty)');

Relational Pseudo-class (:has): Get h1 headings that are followed immediately by an h2 heading:

$headings = $dom->querySelectorAll('h1:has(+ h2)');

Get all paragraphs in an article that have at least one link inside them:

$paragraphsWithLinks = $dom->querySelectorAll('article p:has(a)');

Attribute Selectors: Target specific attribute values, including partial matches (note the ‘i’ to signal case-insensitive matching):

// Find secure external links
$secureLinks = $dom->querySelectorAll('a[href^="https://" i]:not([href*="example.com" i])');

Note: One missing feature is the :scope pseudo-class, which can be used to refer to the current element when there’s a need to use a combinator. Using it currently throws a DOMException. $article->querySelectorAll(‘:scope > p’) This is a known limitation in Lexbor, and it is being worked on.

XPath Selectors

While CSS selectors are an excellent new addition, XPath remains available. I recommend using CSS selectors whenever you can, as they’re usually easier and more concise to write.

In the past, people would turn to XPath because CSS selectors were not as powerful as they are today, and they were not available in PHP natively. Those who wanted to use CSS selectors in PHP had to rely on libraries that converted CSS to XPath under the hood, such as Symfony’s CssSelector component.

Nonetheless, XPath can still be used if you need more complex logic in your selectors or if you’re migrating code that already relies on XPath.

Two-column table mapping CSS selectors to XPath 1.0 expressions, with examples such as div.content, article#main, [src*="avatar"], article p, and article > p, plus two emoji-marked rows showing more awkward XPath equivalents for matching text and links.

Common CSS/XPath selectors

Namespace warning

It’s important to note that if you’ve previously used XPath with HTML parsed with PHP’s DOMDocument, switching to Dom\HTMLDocument will require that you pay attention to namespaces.

 

The new parser assigns namespaces to HTML, SVG and MathML elements, in line with the HTML standard. This means XPath queries that worked before may return empty results. Consider this HTML with an embedded SVG:

<article>
<svg width="200" height="100">
<text x="100" y="50">Hello SVG</text>
</svg>
</article>

With the old DOMDocument, a simple XPath query works without any namespace handling:

$dom = new DOMDocument();
$dom->loadHTML($html);
$xpath = new DOMXPath($dom);
$texts = $xpath->query('//article//svg');
// Works: returns the <svg> element

However, with Dom\HTMLDocument, the same query returns nothing because the article and SVG elements are now placed in the HTML and SVG namespaces:

$dom = Dom\HTMLDocument::createFromString($html);
$xpath = new Dom\XPath($dom);
$texts = $xpath->query('//article//svg');
// Returns empty! The elements are in a namespace.

To fix this, you must register the namespace and use a prefix in your XPath:

$dom = Dom\HTMLDocument::createFromString($html);
$xpath = new Dom\XPath($dom);
$xpath->registerNamespace('h', 'http://www.w3.org/1999/xhtml');
$xpath->registerNamespace('s', 'http://www.w3.org/2000/svg');
$texts = $xpath->query('//h:article//s:svg');
// Works: returns the <svg> element

However, this does have a downsider: if your source HTML uses < template > elements, the contents of those elements will no longer be hidden when working with the DOM.

Serialize to HTML

Serialization is turning the DOM object you’ve been working with into an HTML string. If you need to save the results as an HTML file or store it in a database, you’ll want to serialize.

// Save the entire document
echo $dom->saveHtml();

// Save a specific node (and its children)
echo $dom->saveHtml($dom->body);

Practical Examples

Now that we understand the API, let’s put it to work in some real-world scenarios.

Web Scraping

Here is an example of extracting quotes and their authors into an array:

use Symfony\Component\HttpClient\HttpClient;

// Fetching the content
$client = HttpClient::create();
$response = $client->request('GET', 'https://quotes.toscrape.com/');
$html = $response->getContent();

// Parsing HTML with Lexbor
$dom = Dom\HTMLDocument::createFromString($html);

// Extract quotes using CSS selectors
$quotes = [];
foreach ($dom->querySelectorAll('.quote') as $element) {
$quote = $element->querySelector('.text')->textContent;
$author = $element->querySelector('.author')->textContent;
$authorUrl = $element->querySelector('a[href ^= "/author/"]')->getAttribute('href');
$quotes[] = [
'quote' => mb_trim($quote),
'author' => mb_trim($author),
'authorUrl' => $authorUrl
];
}

print_r($quotes);

JavaScript-rendered HTML

When working with real-world web pages, you will likely encounter HTML that contains shell elements that are then filled with content after JavaScript has been executed in your browser.

If the content you’re after requires JavaScript rendering, you will want to use a headless browser. There are services you can use for this, or if you’re testing locally, you can use Chrome’s –dump-dom flag:

chrome --headless --dump-dom https://quotes.toscrape.com/js/

You can capture the output in PHP with the following:

$url = 'https://quotes.toscrape.com/js/';
$command = 'chrome --headless --dump-dom ' . escapeshellarg($url);
$html = shell_exec($command);

Removing elements

A common task when working with HTML is to remove the bloat that is often interleaved with the content that you want to extract. This can be ads, related links, social media share buttons, and so on.

With CSS selectors, it’s easy to target all these in one comma-separated selector list.

$dom = Dom\HTMLDocument::createFromString($html);

// Remove clutter (scripts, styles, navs, footers)
$selector = 'script, style, nav, footer, aside, .ad-banner, .social-share';
foreach ($dom->querySelectorAll($selector) as $clutter) {
$clutter->remove();
}

Extracting article content

If you’re working with web articles (e.g., news stories, blog posts), I maintain the PHP port of Readability.js, which can be useful to isolate the content HTML automatically before you parse and work on it further.

use fivefilters\Readability\Readability;
use fivefilters\Readability\Configuration;

// Article URL
$url = 'https://www.medialens.org/2020/cogitation-meditation-in-an-age-of-cataclysms/';
// for simplicity we'll use file_get_contents() here
$html = file_get_contents($url);

// Configure Readability
$configuration = new Configuration([
'fixRelativeURLs' => true,
'originalURL' => $url,
]);

// Detect and extract article body
$readability = new Readability($configuration);
$readability->parse($html);
$contentHtml = $readability->getContent();
$dom = Dom\HTMLDocument::createFromString($contentHtml);

HTML Sanitization

When working with HTML you have not produced yourself (e.g., HTML you have fetched, or user-submitted content), you are handling untrusted HTML. Before outputting it for display, you should sanitize it to prevent XSS attacks. Symfony’s HTML Sanitizer component is designed for this.

use Symfony\Component\HtmlSanitizer\HtmlSanitizer;
use Symfony\Component\HtmlSanitizer\HtmlSanitizerConfig;

$config = new HtmlSanitizerConfig()->allowSafeElements()->allowRelativeLinks();
$sanitizer = new HtmlSanitizer($config);

$dirty = '<a href="/page" onclick="alert(\'XSS\')">Click</a>';
echo $sanitizer->sanitize($dirty);
// Output: <a href="/page">Click</a>

The sanitizer automatically strips dangerous attributes like onclick while preserving allowed elements and attributes.

IPC NEWSLETTER

All news about PHP and web development

[mc4wp-simple-turnstile]

 

Migration Guide

Migrating to the new API is generally straightforward, but there are a few key differences to be aware of.

Class Mapping

  • new DOMDocument() → Dom\HTMLDocument::createEmpty()
  • $dom->loadHTML( $ html) → Dom\HTMLDocument::createFromString($html)
  • $dom->loadHTMLFile( $ file) → Dom\HTMLDocument::createFromFile($file)
  • DOMElement → Dom\Element
  • DOMXPath → Dom\XPath

Replacing HTML5-PHP

If you are currently using the HTML5-PHP library, you can likely remove it entirely.

Old way (HTML5-PHP):

$html5 = new Masterminds\HTML5();
$dom = $html5->loadHTML($html);

New way (Native):

$dom = Dom\HTMLDocument::createFromString($html);

Not only is the code cleaner, but you will also see an immediate performance improvement. Note, however, that HTML5-PHP returns the legacy DOMDocument object after parsing, while the new code above returns Dom\HTMLDocument. So you might notice some differences in the API.

PHP 8.5 and the new URI Extension

PHP 8.5 introduces an updated URL parser. When working with HTML, we often also work with URLs. If you’ve used the parse_url function in the past, I recommend switching to the new URI extension.

Conclusion

The introduction of Dom\HTMLDocument in PHP 8.4 is a major update to PHP’s HTML capabilities. It transforms PHP from a language that could do HTML parsing (with enough caveats and libraries) into a language that really excels at it.

Whether you are building a simple scraper or a complex content transformation engine, there has never been a better time to do it in PHP.

Special thanks to Niels Dossche for his incredible work on this extension and Alexander Borisov for the Lexbor project.

Further Readings

The post Better HTML Parsing in PHP: Modern Techniques and Tools appeared first on International PHP Conference.

]]>
Make Better Architecture Decisions https://phpconference.com/blog/monolith-vs-microservices-reduce-coupling-architecture/ Tue, 17 Feb 2026 10:31:21 +0000 https://phpconference.com/?p=107718 What guides us when thinking about software architecture? Chances are you’ve built a monolith at some point – a common starting point for many PHP applications. Maybe you regret this, or maybe you’re perfectly happy and wish people would stop telling you to be otherwise. Let’s look at the debate around monoliths and investigate the good and the bad parts. We’ll explore the alternative architectures, review trade-offs, and bring this learning to see how we can improve our current and future monoliths.

The post Make Better Architecture Decisions appeared first on International PHP Conference.

]]>
We’ll start in section one with an overview of each type of architecture, and arrive at some key principles of what good architecture really is. In section two, we’ll look at the challenges we face with architectural decisions, especially focusing on how different parts of our applications are coupled together. In section three, we’ll draw on our principles to find solutions to the challenges we identified, drawing some aspects of microservices into our monolith applications to get the best of both approaches without major rework.

1. Architecture

The monolith

Monoliths commonly evolve as we build an application bottom-up – adding controllers, views, and new database entities. We tend to couple the various parts of our system together, seeing system interactions as mirroring the related nature of the business or organisation structures that the application supports (our domain). This knowledge is discovered gradually throughout the development process, rather than being fully known from the start.

IPC NEWSLETTER

All news about PHP and web development

[mc4wp-simple-turnstile]

 

Many popular web application frameworks are well-suited to writing monolithic applications (e.g., Laravel, Symfony, Flask, Spring). These frameworks can, of course, be used as components in a microservice architecture as well, but reading common samples, tutorials, or viewing how their codebases are structured points more often towards a monolith.

When you combine the ease of building without a detailed plan and the abundance of well-suited frameworks, it’s no surprise that monoliths are built.

The same characteristics that apply to a codebase often also apply to the data store used for the monolith. This will usually be in the form of a relational database. Whilst they can use specific relationships in the form of foreign keys (and the benefits associated with indexing), it’s generally possible to join or filter on any field. This once again permits an application to choose its data architecture as it grows into it, not restricting where data can live or how it can be related, but allowing flexibility and changes in structure.

Why do we build monoliths?

This “bottom-up” growth of an architecture can be an advantage because when we explore our domain, adding new features is generally just a new file away. Monoliths tend to have the simplest software lifecycle. They exist as single repositories in version control, making them simple to share or build out in feature branches. Components for common tasks such as authentication and logging are readily available. A monolith will keep most server-side logic in a single language, and can all be tested in a single test suite (usually written in the same language).

Depending on the language, a monolith may be deployed as easily as sending files to a remote server. It may require a simple packaging step (such as a zip file) or a compilation step (e.g., transpiling in TypeScript or compilation in Go). In all cases, the resulting server can be replicated by a configuration running locally, and the application is often transferable between remote servers.

At the base level, the monolith is a reliable way to build software, whether as an individual or a team. They are easy to start up, prototype, and ship. Given this potential for an easy life with a monolithic application, how do things look with microservices?

Microservices

Every system has an architecture, whether we designed one or not. So if we want to make good architecture choices, we have to evaluate the options available to us, including monoliths, microservices, and other factors.

In a microservice system, we split different functionalities into distinct applications. Microservices platforms intend to reduce the likelihood of coupling between unrelated components by keeping them separated. This makes it possible for services to be in different repositories and to be built, tested, and deployed independently. Splitting the codebase into different services often aligns with different teams that may work on these services. It can even allow different services to be written in different languages/frameworks.

By aligning services with teams, microservices aim to enable more agility, as, ideally, a team can make improvements or solve bugs in their own service without the risk of affecting others. These changes should be quicker to verify, as each application would presumably be smaller than the entire stack of a monolith, with fewer tests to run and a lower risk of developing complexity.

Communication between services

Services will communicate via synchronous or asynchronous means. Synchronous communication tends to use some form of network request (HTTP or gRPC) and is analogous to a function call within a single system. This form of communication will be similar to how a lot of API tools between unrelated parties (so-called 3rd party APIs) work, with defined requirements (in the form of OpenAPI specifications or contract tests) in terms of requests and responses.

YOU LOVE PHP?

Explore the PHP Core Track

 

Asynchronous communication may use a stream (e.g., Kafka), a queue (RabbitMQ), or a notification system (AWS SNS). The chosen mechanisms for asynchronous communication all have quite different use cases – streams or notification systems allow sending a message without any intent of receipt, whilst queues tend to have the goal of executing a specific task. Asynchronous communications may mean one service can communicate without knowledge of another, but for actual work to happen in response to an event on a stream, a consumer must have insight into what messages are likely to be sent.

If microservices have different codebases, they may also have different data stores. Indeed, the increasing popularity of microservice architectures has happened around the same time as more distributed methods of data storage, like NoSQL databases, have entered the general consciousness of developers. With independent data stores, services can adapt better to their own frequencies of reads & writes and use data structures that fit their use cases better. For example, a payment system may be highly transactional and need very reliable write performance and atomicity. This is different from an emailer service that needs to perform complex segmentation queries in batches, but whose durability may matter less as long as the overall throughput is maintained.

Monolith & microservice misconceptions

The Wikipedia page describing Monolithic Applications makes a poor case for them. Indeed, it seems to be written by someone who doesn’t like the concept of a monolith: “A monolith is less available, less durable, less changeable, less fine-tuned, and less scalable than a well-designed distributed system.”

This use of “less than” places monoliths in a negative space. In every way, they are lesser than their microservice counterparts. However, we could look at the microservice architecture in the same way:

It’s only as available (and as durable) as its least available (and durable) synchronously coupled service. It’s only as changeable as we have an appetite to maintain versions of our internal APIs for, and it’s scalable as long as we keep the same access patterns we thought of when we started building it.

In fact, the only part about monoliths that rings true is the last part: “well-designed.”

The rise in popularity of microservices may come in part from their use at large enterprise organisations, where teams and systems reach a scale where attempting to use monolithic architectures may severely hamper progress due to the challenges of cross-team communication. This is not a present worry for development teams in the majority of organisations, but we should be careful not to follow a pattern simply because it is discussed by people working on prestigious teams or projects.

In the next section, we’ll explore a few key principles and evaluate how both architectures measure up against them. We’ll then look at how to apply these principles to improve our applications, regardless of the architectural approach we choose.

Key principles

  1. Coupling creates complexity in systems. Whilst we want to reduce this, many real-life interactions (which our systems are modelled around) are themselves complicated and messy, so we can never reduce complexity all the way.
  2. We need coupling to be clear to both sides of any service interaction and the different people who may be working with it.
  3. Smaller services will always have an advantage in speed of testing or deployment. However, if they introduce version dependencies between services, this advantage can be significantly reduced.
  4. Decoupled data stores can suffer the same problems of consistency and hidden coupling as service codebases. There may be times when independent services can agree to work with the same data store if that store can have a consistent schema.

Firstly, let’s see the problems monoliths come up with when we evaluate the architecture.

2. Challenges

Cracks in the monolith

In software applications, coupling refers to connections between pieces of code where the execution of one piece of code requires the presence of another.

For example, in many software-as-a-service applications, the “user” may be tightly coupled to many other concepts. Users may own specific data entries, have authentication services, manage billing, and even administer the software. In this context, the “User” entity and associated logic may be coupled to most or all parts of the codebase. Modifications to the “user” as an entity, or to the concept of what a user is/does, may impact any part of the software. The tight coupling of “user” results in brittle software; a small break in one part may result in the whole application breaking.

Codebase coupling

With all software being written in one language and one codebase, coupling is likely to occur. This is especially true if building from the bottom up. As we start to add features, we reuse parts of code that seem similar, prematurely abstracting them and later resulting in a mess of configuration options. Consider the following example:

class Utils {
  /**
    * Output our address components
    */
  public static function addressFormatter(Address $address) :string {
     return implode(', ', array_filter($address->toArray()));
  }
}

But we re-use it more and more, adding parameters to meet a widening range of use cases, and progressively making the internal logic harder to reason about:

class Utils {
  /**
    * Output our address components
    */
  public static function addressFormatter(Address $address, string $separator = ',', bool $hide_number = false, bool $show_country = false) :string {
     $arr = $address->toArray();
     if ($hide_number){
       unset($arr['number']);
     }
     if ($show_country){
       $arr[] = $address->getCountry();
     }
     
     return implode($separator, array_filter($arr));
  }
}

Eventually, a change in one part of the application will unexpectedly break something elsewhere. While this example may be contrived, tight coupling is often a natural consequence of architectures that evolve alongside application logic without clear boundaries. Monoliths are particularly well-suited to this style of development, which is why they often end up being built this way.

Database coupling

Just as services can become coupled, so too can the database entities underlying a monolith. These act as a form of asynchronous coupling between parts of the system that may not appear directly connected in the code. It’s often only when multiple components rely on the same data and expect it to behave consistently that we realize seemingly unrelated parts of the codebase are, in fact, tightly linked through shared database structures.

As well as adding the normal risks of maintainability, coupling may also result in redundant data, fields on a table of 1,000 rows just used for one or two cases, because of a specific niche use case.

Another risk of keeping all data stored in a single source relates to how the infrastructure is handled. As different features require improved query performance, better transactional locks, or higher throughput, the entire platform must scale in all directions to accommodate each requirement.

IPC NEWSLETTER

All news about PHP and web development

[mc4wp-simple-turnstile]

 

Size constraints

Some of the software lifecycle advantages of monoliths may also become disadvantages over time. The singular test suite may take longer to run (even if it can be parallelised), and because the monolith may permit any part of the code to use any other part, all tests need to be run even for small changes. Not only this, but coverage becomes more important, as manual testing or QA on a change may not catch unintended changes. Depending on the form of deployment, changes may also take longer to deploy as the application increases in size. Again, the whole application must be redeployed for each change (or wholesale rolled back in the case of a defect in a single feature).

Given these limitations, monoliths are often seen as a viable approach, at least up to a certain application size. But this raises a difficult question: where exactly is the tipping point where a monolith starts to break down under its own weight? While many projects begin small, it’s common for prototypes to end up in production without the rewrite they clearly need. This reality makes it tempting to start with the architecture we hope to end with. But how well do microservices hold up when judged against the same principles?

The awkward thing about microservices

The idea of independent services, potentially written in different languages, that are fast to build, test, and release, is appealing. It’s especially attractive when you’ve been wrestling with a highly coupled monolith that breaks in new and unpredictable ways with every small change we make.

However, just because it’s no longer easy to reuse a function in an unrelated component, our microservices often still end up coupled together through their chosen mechanisms of communication. The considerations that go into good architecture are still important, but just utilising microservices won’t be enough.

Communication coupling

Coupling is especially easy with synchronous systems, as API calls effectively replace function calls. This can have multiple drawbacks, firstly in the surprising area of error handling and fault-tolerance.

If one service relies on another synchronously, the uptime of the overall system reduces to the lowest uptime within the system. This is not necessarily worse than a monolith, where the whole system may be heavier and thus harder to keep up, but it doesn’t suggest the uptime may be better either.

Even with a good level of uptime, networks are more error-prone than function calls within an application. Remote procedure calls can fail for various reasons and cause an error state. This means each application must be able to work out retries or determine appropriate fallback behaviour when its dependent service is unavailable.

Another challenge for synchronous systems lies in the contract between the services. If one service wishes to modify the data it would like to receive, or data it wishes to send in response, the other system may need to adjust its own interactions. Versioning of the API requests and responses will be required so that, as one system is upgraded, other systems continue to operate with it.

If both services are managed by one team, it may be possible to operate with only a single version difference between two coupled systems; upgrade system A, then upgrade system B. However, when multiple systems depend on system A, and especially if those are managed by different teams, system A may need to support multiple versions simultaneously to allow dependent systems time to update.

Asynchronous communications

Asynchronous interactions are more manageable, but only if they truly are asynchronous. For example, in the context of pub-sub or stream-based architectures, it’s generally assumed that a service may publish information without any requirement that this is acknowledged or acted on. In an ideal setting, this means a service can publish whatever it wants, even multiple versions or permutations of the same message, and it is up to the consumer to choose what to act on.

These ideal settings are challenged when running up against real-life examples, where two actions may not need to happen instantly after one another, but where there is still an expectation that one follows the other.

A checkout system is a common part of many platforms, and may seem simple enough. We take customers through reserving items in a basket, entering shopping details, and making a payment. Once the payment clears, we dispatch the order and send a receipt.

However, even in a seemingly straightforward case, it’s worth asking: which parts are truly asynchronous? Each step typically depends on the successful completion of the previous one. The only arguably “optional” part might be the receipt email. But if that consistently fails, customers are likely to be unhappy. Whether this system is split into multiple services or consolidated into a single process doesn’t eliminate the challenge that the entire workflow is expected to work reliably. Putting multiple services into one process can sometimes obscure real issues, making them harder to detect and resolve.

Decoupling databases

If reducing coupling seems difficult, that’s because it genuinely is. One reason monoliths tend to grow over time is that the businesses or organisations that our software is a map of often evolve in a similarly unplanned and unstructured way. We add extra services or introduce exceptions to established patterns to seize specific opportunities or mitigate particular risks.

The same is true of all the data that our organisations create; databases become a form of asynchronous communication (and thus coupling) in their own right. We may decide to move all user authentication to an auth service with its own data store for user information. As users interact with unrelated services, we can take a copy of key information for the user and add it to custom settings pertinent to the specific application. What do we do in this case when a user wishes to modify their email address or password? Maybe that data lives just with the authentication layer, but does that then become the service that has to send them email notifications, or the newsletter?

We’ve likely all used services where navigating to a single feature involves being bounced across multiple subdomains, so many that even the browser seems dizzy by the time we get there. In reality, the idea of one user accessing multiple features tends to couple the system together anyway, as users expect a consistent, coherent experience across the entire platform.

The data-access patterns of an application built with microservices can also become a challenge. We may have designed one part of the system for high transactional load and the other for batch analytics, but what happens when we want to analyse the correlation between our newsletter signups and recent purchases, or how many people unsubscribe after a payment failure? As with monolithic codebases, there is an advantage to putting all our data in one place and working out how we want to use it later, as often we don’t know the facts up front.

3. Solutions

Putting it together

Monoliths have one set of problems, while microservices come with another. Neither exists solely to fix the problems of the other, and both can be well designed (or not). One could argue that the key advantage of microservices is that when things go wrong, the issues tend to surface much earlier than they would in a monolith, where cracks can take longer to appear. However, this is only an advantage if those early warning signs lead to meaningful change, rather than simply doubling down on microservices and assuming they’re the right answer.

If we are aware of the pitfalls inherent in how a monolith grows, especially in how code and data can become coupled, then we can avoid these aspects without needing to completely change what form of architecture we are using.

By understanding the pitfalls of how a monolith grows, particularly how tightly coupled code and data can become, we can then avoid those issues without necessarily abandoning the form of architecture we are using altogether.

Putting together these principles, it’s clear that whether we’re working with an existing monolith or choosing to build a new one. We can learn from the microservice world to adopt better architecture.

EVERYTHING IS CONNECTED TO THE INTERNET

Explore the Web Development Track

 

Reducing code-level coupling

Whilst code-level coupling is easier to introduce than communication coupling, it’s also easier to spot. This is especially true in smaller teams that may work on many different components in an application, but it can also apply to larger ones working on monolithic applications as well.

One way to do this is by writing our code according to its Domain, rather than its type. Many applications are written with directory structures like:

src
 |- api
  |- user
  |- order
 |- entities
  |- user
  |- order
 |- exceptions
  |- user
  |- order
 |_ services

Whereas we can actually organise them like:

src
 |- user
  |- api
  |- exceptions
  |- entities
  |_ services
|- order
  |- api
  |- exceptions
  |- entities
  |_ services

In doing this, we start to be able to apply rules – items in the “user” directory should not be able to call public methods or create objects of classes in the “order” directory.

Some languages have features to help enforce this. For example, the popular PHP static analysis tool psalm has the @psalm-internal flag, allowing a namespace to be specified where a call is permitted:

namespace UserDomain {
    class AddressService {
        /** @psalm-internal UserDomain */
        public static function formatPostcode(string $string):string{
            return '';
        }
    }
}

namespace OrderDomain {
	class InvoicePrinter {
        public static function address(string $postcode):string{
            return \UserDomain\AddressService::formatPostcode($postcode);
        }
    }
};

When Psalm analyzes the above code, it will output the error:

The method UserDomain\AddressService::formatPostcode is internal to UserDomain but called from OrderDomain\InvoicePrinter::address

Not all languages have a similar pattern. However, for those that don’t, it’s often a requested feature. C# has the internal keyword, much as methods or fields may be declared protected or private.

Declaring relationships

It’s perfectly reasonable that services need to interact with each other, whereas the “internal” concept (whether enforced in code or not) prevents this in the same way two independent services would prevent it. Just like independent services, some form of communication pattern is necessary. Once again, this reintroduces coupling, but when done in a codebase, it is possible to make coupling very explicit using the paradigm of event handlers.

In this case, a method that carries out an action dispatches an event. Rather than this being an interaction with a communication protocol such as stream, it is handled within the application code:

class UserService {
   public function changePassword(User $user){
     // code that changes password
     $this->dispatch(new ChangedPassword($user->getId()));
   }
}

In the application bootstrap:

// Audit-service bootstrap
$dispatcher->addListener(ChangedPassword::class, function($event){
  $user_id = $event->getId();
  
  $this->logAuditTrail($user_id);
})

Many frameworks (e.g., Symfony or Laravel in PHP) provide event listeners as part of their framework, but they are relatively straightforward to implement as well. This creates a specific relationship viewable in code (rather than via API contracts, or by trying to read five different repository configurations to work out what is responding to what) that still enforces segregation of domains.

One useful tip when dispatching events is to only use scalar data in the event. This pushes services to rely on the simplest data points from another service and adds another barrier to just utilising internal concepts of a data store, which may have to be changed in the future.

Satellite services

While it’s encouraging to see that domain isolation and clarity of coupling can be introduced in a monolith without rewriting it to microservices, there are still benefits to microservices that can’t be reproduced easily using just a monolith. Smaller services can be tested and deployed more rapidly. They can also use different infrastructure to suit specific use cases.

Fortunately, this benefit is still open to monoliths if we use the idea of satellite services. A satellite service combines elements of monolith and microservice. The satellite is functionally independent from the monolith. It can have its own repository (although shipping the code alongside the monolith may be simpler for our team), be in its own language, and have its own test suite. What makes this different from a microservice is that we avoid the risk of coupled communication by keeping the data flowing in a single direction.

The main challenge from microservices communication is the concept of a request and response, and so each service interaction requires the understanding of two sets of data formats. Service A needs to send data in the right format and understand the response. If service B can also talk to service A, this problem is doubled.

When using a satellite service, we try to offload a very specific piece of data processing to another service without creating dependencies in both directions.

IPC NEWSLETTER

All news about PHP and web development

[mc4wp-simple-turnstile]

 

Satellite use cases

For example, imagine a customer analytics application that wants to start ingesting data directly from third-party merchants. While this adds a valuable new feature, it also introduces a sudden infrastructure burden. The monolith that was originally built to support a data visualisation Saas platform is unlikely to handle a firehose of metrics from third-party websites, especially when that data requires real-time processing and storage.

This is where the satellite service comes in. Given a very specific known schema for how the application already needs its data processed and stored, a service can be written to provide this third-party ingest function separately, offloading processing to custom infrastructure before delivering the data to the base system. The data flows in one direction, and the satellite doesn’t need to know anything about how the data is eventually used. In the event the schema does need to change, the satellite will need to be upgraded first, but doesn’t need to be versioned; it can just output data in both old and new formats, and the application can choose which cases it uses.

The same would be true of a satellite service existing only to extract some specific piece of data and provide it to a third party (for example, as part of API middleware). Once again, if the data structure in the monolith is to change, the satellite must adapt first and be able to handle both old and new formats. There is no need here to maintain versions long term, as previous versions are set in stone and can remain without an extra maintenance burden, because the versioning is in the satellite, not the monolith. With a clear relationship between small satellites and the main monolith, we avoid the challenge of a mesh of services all needing different versions of one another (the so-called “big ball of mud”).

Diagram of a monolith with two external systems: one sending data into it and one receiving data from it, illustrating inbound and outbound data flows.

E(xtract) T(transform) L(oad)

When microservices can use independent databases without running into the challenge of coupling between data stores, they gain some advantages. A big aspect is that many databases excel at specific requirements, but of course, no single database excels at everything.

The two main patterns of database usage are transaction processing and analytics. We often do these operations on the same or similar blocks of data, and relational databases can handle both well. However, depending on our throughput and load distribution, we may find that intensive write operations require one type of scaling while intensive read ones require another. Furthermore, these operations can interfere with each other; reads may be blocked by row-level blocks during writes, or the server may become overwhelmed in terms of processing power or memory when handling both operations.

In a traditional relational database, we can often solve this problem using read replicas, carrying out analytics on a secondary storage mechanism that can lag behind the primary, but in a way that this lag doesn’t cause problems for the data we’re trying to extract. The two databases can be sized differently, though they are often tied together in the same network, share the same data (mostly), and run on the same database platform.

A pattern we can learn from microservice architectures and apply back to our monolithic applications is Extract, Transform & Load. In these cases, we run a process that doesn’t just clone data between identical data stores on different hardware, but lets us modify the data and normalise or denormalise it for a different purpose. We can build ETL systems using a range of different options:

  • ETL within a single database using triggers – useful for denormalising data to create more effective structures for specific search or analytics queries.
  • Using our transactional store on a NoSQL serverless platform like Amazon DynamoDB, we can read from a stream output to load data into one or multiple different SQL or other data stores, providing the ideal structure for each case (e.g., changing a nested document set into a range of rows across relational tables).
  • For data stored in file or object storage as JSON, use batch processing to move this into a database for analytics, such as Snowflake or Redshift.
  • Some tools, such as AWS Athena or Glue, also allow us to run SQL-like operations on file storage, using a series of crawlers or indexes that are run on a schedule.

By recognising that the way we generate or store data can be decoupled from how we use it, even within a monolithic platform or one data store, we gain more options when it comes to how we use our data while avoiding the risk of different use cases interfering with one another.

4. Conclusion

The real challenge in building systems that remain manageable isn’t whether we choose a monolithic or microservices architecture, but how well we understand and manage the dependencies within our application, and, by extension, within our domain. If we’ve taken advantage of the simplicity and rapid development benefits of a monolith, we can still avoid the pitfalls of tight coupling by carefully structuring our code and enforcing clear boundaries around which parts of the application can interact, even within a single codebase.

We can use the best infrastructure or language for specific jobs by adopting satellite services, where we avoid excessive intercommunication problems by keeping data flowing in a single direction.

We have to acknowledge that our data storage will still be part of our communication layer and a place where accidental coupling can occur. However, clear rules within our application about where data belongs can help mitigate this, and our satellite services can be part of reformatting data for specific use cases when we need it.

If we’ve already built microservices, understanding our structure may not have fixed all our coupling. It is helpful to reformat or, at least, aim to make these dependencies explicit wherever they occur.

It’s natural for the architecture of the software we build to evolve around us. We can steward this evolution to manage complexity and reduce coupling, making it clear where our key dependencies are. Without needing to tear down our monolithic applications, we can take advantage of domain separation, satellite services, and data management strategies, building a healthy application by understanding the core principles of how services grow and communicate together.

moments: replacing a list of constants with an Enum, adding readonly to a DTO, or swapping a fragile switch for a robust match expression.

These features – Enums, Readonly, Named Arguments, Match, Property Hooks, and Attributes – are just a small set of the goodies made available in recent PHP releases, but they provide clearer intent, safer defaults, and expressive data. They turn quiet, insidious bugs into loud, fixable errors. They reduce boilerplate and let us focus on the business logic that actually matters.

So, here is the challenge: Pick one feature from this article. In your next pull request, try to replace a set of constants with an Enum, or use named arguments in a confusing function call. Start small. You will find that these small upgrades accumulate into big quality-of-life wins for you and your team.

Enjoy a better app, and an easier life!

The post Make Better Architecture Decisions appeared first on International PHP Conference.

]]>
Modern PHP Features You’re Probably Not Using (But Should Be) https://phpconference.com/blog/modern-php-features-safe-code/ Mon, 19 Jan 2026 17:02:54 +0000 https://phpconference.com/?p=107680 Silent failures, "mystery" boolean flags, and fragile switch statements are the ghosts of PHP versions past. By embracing modern features like Constructor Property Promotion, Intersection Types, and the #[Override] attribute, you can transform insidious runtime bugs into loud, fixable compile-time errors. Discover the small syntactic changes that deliver clearer intent, safer defaults, and a better quality of life for your entire development team.

The post Modern PHP Features You’re Probably Not Using (But Should Be) appeared first on International PHP Conference.

]]>
We developers start every day standing at a crossroads. On one side, there is the allure of the latest and greatest – the shiny new features, the RFCs that just dropped, and the tools that promise to revolutionise our workflow. On the other side lies reality: a growing list of bug fixes, looming deadlines, and the day job that actually pays the bills.

YOU LOVE PHP?

Explore the PHP Core Track

 

It is a common scenario for all of us. I look around at conferences and see rooms full of people who have traveled far and wide, enthusiastic to learn, yet we all face the same blockers when we return to our desks. Despite our best intentions to stay current, the pressure to ship often forces us down the path of least resistance. It is safer, and often faster, to copy a familiar pattern from elsewhere in the codebase – even if it’s five years old – than to research and implement a new language feature.

This leads to a subtle ossification of our applications and us as developers. We end up in a situation where we might have 20 years of experience, but it looks suspiciously like the same year of experience repeated 20 times! This reality is reflected in the PHP ecosystem statistics, where sticky older versions like PHP 7.4 persist in the usage charts. Even those of us lucky enough to be running PHP 8.3 or 8.4 are often guilty of writing “PHP 7 code in a PHP 8 world”.

But modernisation doesn’t require a “stop the world” rewrite. By adopting specific, high-impact features introduced in recent versions, we can achieve what we all want: a strong, stable core for our applications. This article explores these features. This is not an exhaustive list of every shiny new toy, but more a curated guide to small changes that yield big wins for your applications and your overall quality of life!

Cementing the Foundation: Data Integrity

When building a house, you start with the foundation. For developers, our foundation is our data. Is it behaving the way we think it is? Is it validated correctly? Can we rely on it?

The Tyranny of Magic Strings

Let’s look at a pattern we have all written a thousand times. You have an Article object with a status property. You write a conditional to handle the flow:

if ($article->status === 'published') {
    publishToFeed($article);
} elseif ($article->status === 'revieew') { // Oops
    sendToEditor($article);
} else {
    logError('Invalid status: ' . $article->status);
}

If you look closely at the code above, you might notice the very eccentric spelling of ‘review’. Because this is a magic string – a string literal with special meaning but no formal definition – PHP happily accepts my fat-fingered typo. Your IDE can’t help you because, as far as it knows, you meant to type ‘revieew‘. The result is a subtle bug where articles that should be under review silently fall into the else block and trigger an error state.

These magic strings are fragile. They are hard to maintain, and they offer zero support from our tooling.

The Solution: Backed Enums (PHP 8.1)

Enums (Enumerated Types) are often misunderstood as just a fancy way to group constants. While they do group constants, their real power lies in the fact that they are a type understood natively by PHP.

By defining an ArticleStatus Enum, we replace fragile strings with robust types:

enum ArticleStatus: string {
    case Draft = 'draft';
    case Review = 'review';
    case Published = 'published';
}

Now we can type-hint our methods. Let’s consider the case of setting the status on an article, based on a form a user has submitted.

$status = $_POST['status'] ?? null;
// Some validation on $status, then...
$article->setStatus($status);

public function setStatus(string $status): void
{
    // Do we need to validate $status again, just in case?
    ...
    $this->status = $status;
}

Instead of accepting a generic string $status, we accept ArticleStatus $status. If we try to pass a typo or an invalid string to a function expecting this Enum, PHP throws a TypeError immediately.

$status = ArticleStatus::tryFrom($_POST['status] ?? null);
$article->setStatus($status);

public function setStatus(ArticleStatus $status): void
{
    $this->status = $status;
}

The “Marketing Manager” Problem

Enums become even more powerful when we use them to centralise domain logic. Consider the presentation layer. Let’s say we have a search filter dropdown iterating over our statuses, capitalising the first letter to create a label: “Draft”, “Review”, “Published”.

<select name="status">
  @foreach (['draft', 'review', 'published'] as $status)
      {{ ucfirst($status) }}
    
  @endforeach
</select>

Then, a new Marketing Manager joins the team. They decide that “Draft” isn’t punchy enough. They want it to say “Pre-publication”.

A developer picks up the ticket, finds the specific loop in the search filter, and hard-codes the change. But they miss the status pill on the article detail page. Suddenly, you have the same data concept labeled differently across your app. This leads to the kind of “Inconsistent Labels” Jira ticket that absolutely ruins your Friday afternoon.

Remember that Enums are more than just a collection of constants. We can solve this by adding a method directly to the Enum itself:

public function label(): string {
    return match($this) {
        self::Draft => 'Pre-publication',
        self::Review => 'Awaiting Review',
        self::Published => 'Live',
    };
}

Now, the presentation layer doesn’t need to know what the label is; it just asks the Enum for its label. We update it in one place, and consistency flows through the entire application.

<!-- Our filter -->
<select name="status">
  @foreach (ArticleStatus::cases() as $status)
    <option value="{{ $status->value }}">
      {{ $status->label() }}
    </option>
  @endforeach
</select>

<!-- Article detail page -->
<div class="pill">
  {{ $article->status->label() }}
</div>

Validating Input with tryFrom

Input validation is another area where Enums shine. Typically, you might check if a submitted string exists within an array of valid options using in_array(). This leads to validation logic scattered throughout controllers, and inevitably, somebody updating the “magic list” in one place but not the others!

$status = $_POST['status'] ?? null;

// Check the magic list of accepted values..
if (!in_array($status, ['draft', 'review', 'published'])) {
    throw new InvalidArgumentException('Invalid status = '.$status);
}

With Enums, you can use the tryFrom() method:

$status  =  ArticleStatus::tryFrom($_POST['status']  ??  '');

If the input matches a valid backing value, you get the Enum instance. If not, it returns null. This allows you to fail fast and eliminates the need for manual validation logic. You check it once, convert it to an Enum, and from that point forward, your application relies on a strict type rather than a vague string.

Immutable Data: Readonly Properties and Classes

We often encounter data that should not change once it is created, such as a Data Transfer Object (DTO) or a Value Object representing a physical address.

Consider a standard Address class with public properties. You might instantiate an address for “123 Main St, Dublin, Ireland.” Ten lines later, a developer creates a bug by accidentally reassigning the country:

$address = new Address('123 Main St', 'Dublin', 'Ireland');
// ... some logic ...
$address->country = 'United Kingdom';

Taking the geopolitical implications of moving Dublin to the UK aside, this is a code issue we want to avoid. We don’t want the state of our objects to change unexpectedly after instantiation.

The Fix: Read-only (PHP 8.1/8.2)

By adding the readonly modifier to the property (or the class in PHP 8.2), we instruct PHP to enforce immutability.

readonly class Address {
    public function __construct(
        public string $street,
        public string $city,
        public string $country
    ) {}
}

Now, if code attempts to modify the $country property after initialisation, PHP throws a loud, fatal error: Cannot modify readonly property. We have converted a potential silent logic failure – one that could corrupt tax calculations or shipping routes – into an immediate crash that forces us to fix the bug. This is part of a broader trend in these PHP updates – helping protect us from ourselves, and the previously silent failures which have tripped so many of us up over the years.

The “Gotcha”: Internal Mutability

There is a nuance to readonly that can trip developers up: Objects within readonly properties can still be mutated unless they are also immutable.

Consider an Article class with a public readonly DateTime $publishedAt property. If you try to replace $publishedAt with a new DateTime object, PHP will stop you. However, if you call a modifier method on the object itself, PHP will allow it:

// This throws an error
$article->publishedAt = new DateTime(...);

// But this is allowed!
$article->publishedAt->modify('+1 month');

Even though the property is read-only, the internal state of the DateTime object is not. It is not “frozen” in stone. To achieve true immutability, you must ensure the types you use are also immutable, such as using DateTimeImmutable instead of DateTime. If you switch to DateTimeImmutable, the modify method returns a new object rather than changing the existing one, effectively locking down the state.

Framing the Structure: Control Flow & Safety

Once we have a stable core of data, we need to frame the structure of our application logic. We want to keep out the wind and the rain; we want to avoid ambiguity and silent failures that let bugs slip through unnoticed.

Precision with Union & Intersection Types

One challenge we have battled for years involves systems that look up data by ID. Originally, your find($id) function accepted an integer. Then, an SEO audit happened, and suddenly, you needed to support slugs. So, you removed the type hint and updated the DocBlock to say @param int|string $id. But as we know, comments aren’t contracts.

A DocBlock is a hint, not a guarantee. In a legacy codebase, nothing stops a developer from passing a float, an array, or a null into that function. The application won’t crash at the entry point, but will crash deep inside the logic, creating another type of silent failure that is painful to debug.

Union Types (PHP 8.0) allow us to move that logic from the comment into the code:

function find(int|string $id)
{
    // ...
}

Now, the contract is enforced by the engine. If you pass an array, it explodes immediately.

Intersection Types (PHP 8.1) handle the opposite problem. Sometimes, you don’t care what an object is, only what it can do.

function handle(Cacheable & Responder $component)
{
    $key = $component->getCacheKey();
    $response = $component->respond();
    // ...
}

Here, we aren’t forcing the component to inherit from a specific parent class. We are saying, “I don’t care what class you are, as long as you satisfy the Cacheable AND Responder contracts.” It eliminates ambiguity and creates precise, enforceable boundaries in your application structure.

The “Switch” Trap

The switch statement is a common source of bugs. Many developers have a mental model of it as being similar to an if/else, when in reality it is essentially a glorified goto statement. It suffers from two major issues:

  1. Fall through: If you forget a break, execution continues into the next case.
  2. Type Coercion: switch uses loose comparison (==).

Consider a switch statement, checking a status. If casereview‘ matches but you forget the break, the code falls through and executes casepublished‘ immediately after. You end up with an article that is theoretically “Under Review” but is actually labeled “Published”.

$status = "review";
switch ($status) {
    case "draft":
        $label = "Draft";
    case "review":
        $label = "Under Review";
    case "published":
        $label = "Published";
}

echo $label;
// Expecting: "Under Review", but result:
// Published

To fix this in a switch statement, we have to litter the code with break statements, making the statement about 33% longer just to manage the boilerplate.

$status = "review";
switch ($status) {
    case "draft":
        $label = "Draft";
        break;
    case "review":
        $label = "Under Review";
        break;
    case "published":
        $label = "Published";
        break;
}

echo $label;  // "Under Review"!

The Solution: Match Expressions (PHP 8.0)

The match expression addresses these flaws head-on. It uses strict comparison (===) and prevents fall-through automatically.

$label = match ($status) {
    'draft' => 'Draft',
    'review' => 'Under Review',
    'published' => 'Published',
};

Perhaps most importantly, match must return a value. In a switch statement, if no case matches and there is no default, the code simply proceeds, potentially leaving variables undefined. With match, if no condition is met, PHP throws an UnhandledMatchError.

This turns a silent failure mode into something loud, aggressive, and in-your-face. While a fatal error sounds scary, it prevents those “niggly paper cuts” where invalid states persist in your database for months because the code silently failed to handle a specific case.

Refactoring Tip: match(true)

A powerful pattern is match(true). Instead of matching a value, you match the boolean true against a series of expressions. The first expression that evaluates to true wins. This is excellent for replacing complex if/elseif chains, such as determining age ranges:

$result = match (true) {
    $age >= 65 => 'senior',
    $age >= 25 => 'adult',
    default => 'kid',
};

This syntax flips the logic around and makes complex conditionals much easier to scan. The PHP Docs include a nice example of using this structure to solve FizzBuzz, which is worth checking out.

Named Arguments: Solving the “Mystery Boolean”

We have all seen legacy functions that have grown “Christmas tree” ornaments over time – optional parameters tacked onto the end.

sendNotification($user,  'Subject',  'Body',  true,  false,  true);

What do those booleans do? Is the first one “urgent”? Is the second one “send email”? Who knows? You have to dig into the function definition to find out.

Named arguments (PHP 8.0) solve this readability issue:

sendNotification(
    user: $user,
    subject: 'New Comment',
    body: 'Someone replied!',
    urgent: true,
    ccTeam: false,
    addTrackableLinks: true
);

This is self-documenting. It also makes your code refactor-safe. If the parameter order changes in the function definition, your named arguments will still work perfectly because they are bound by name, not position.

This is also helpful when you are dealing with a parameter that has picked up a lot of optional arguments over the years, and you only care about setting the last one. PHP historically doesn’t let you set a later optional argument and leave an earlier one empty, so often you’d end up copying in default values for the earlier parameters just to get to the one you care about. Then later on, someone changes the default, and your code is now setting values it didn’t really care about in the first place. Note $subject in the example below – we only ever wanted to set $metadata, but had to set a default subject, which has since changed in the constructor.

readonly class ArticleDTO
{
    public function __construct(
        public string $title,
        public ArticleStatus $status,
        public UserRole $authorRole,
        public string $subject = 'Updated default subject',
        public array $metadata = []
    ) {}
}

$article = new ArticleDTO(
    'Modern PHP Features',
    ArticleStatus::Published,
    UserRole::Editor,
    'Default subject',      <-- Don't care, but needed to fill it ['key' => 'val']		<-- The one we care about setting!
);

With named parameters, because the order doesn’t matter, there’s no longer a need to set earlier optional values in the argument list. We can omit them altogether and only set the values we care about.

$article = new ArticleDTO(
    title: 'Modern PHP Features',
    status: ArticleStatus::Published,
    authorRole: UserRole::Editor,
    metadata: ['key' => 'val']
);

A word of warning: don’t overuse this for simple functions with one or two arguments, or your controllers will start to look like YAML soup! But for those complex legacy functions, it can be a lifesaver and a great help for readability.

Making it a Home: Quality of Life Improvements

Finally, we want to make our codebase a nice place to live. We want to plant a garden, paint the walls, and generally reduce the cognitive load required to work in the application.

Boilerplate Reduction: Constructor Property Promotion (PHP 8.0)

Historically, creating a simple class in PHP involved a lot of boilerplate: defining properties, writing the constructor, and assigning arguments to properties. It was repetitive and prone to drift if you changed a variable name in one place but missed another.

class Article
{
    private string $title;
    private string $author;
    private bool $published;

    public function __construct(
        string $title,
        string $author,
        bool $published
    ) {
        $this->title = $title;
        $this->author = $author;
        $this->published = $published;
    }
}

Constructor Property Promotion collapses this entire dance into a single definition:

class Article
{
    public function __construct(
        private string $title,
        private string $author,
        private bool $published
    ) {}
}

It is cleaner, shorter, and removes the noise. Personally, deleting 20 lines of boilerplate and replacing them with just 4 or 5 gives me a significant dopamine hit! We’re declaring the variables and their visibility in one go, while also allowing them to be automagically assigned.

A Note on AI: I recently ran an experiment asking several AI coding assistants (ChatGPT, Claude, Gemini) to generate a simple PHP class. Interestingly, almost all of them defaulted to the old, verbose, pre-PHP 8.0 syntax. When I challenged them on why they didn’t use promoted properties, they admitted they knew the better way, but “defaulted to the traditional way out of habit”. It was a fascinating Turing Test moment – the AI proved it was just as prone to bad habits as a human developer who hasn’t updated their knowledge in five years! This makes sense, with the AIs using statistical models – there are way more examples out there of older code than new. However, it serves as a reminder that we cannot blindly rely on AI to modernise our code. We have to know what features exist so we can ask for them explicitly.

Property Hooks (PHP 8.4)

A brand-new feature, Property Hooks, allows us to define get and set logic directly on a property. This eliminates the need for verbose getter and setter methods that clutter up our classes.

public string $fullName {
    get => $this->first . ' ' . $this->last;
    set => [$this->first, $this->last] = explode(' ', $value);
}

This keeps the logic for a property co-located with the definition of the property itself. It feels very similar to computed properties in languages like Swift or C#, showing how PHP continues to evolve by learning from other ecosystems.

The #[Override] Attribute (PHP 8.3)

In object-oriented PHP, it is easy to accidentally break an application when refactoring a parent class. If you rename a method in a parent class, but a child class was overriding that method, the child class’s method is now technically a new method, not an override. The link is broken silently, and the parent method starts executing instead of the child’s logic.

I recently encountered this with a subtle typo: a child class implemented handelRequest (misspelled), while the parent had handleRequest. There was no syntax error, just a silent failure where the wrong function ran.

By adding the #[Override] attribute, you explicitly tell the PHP engine: “I intend for this to override a parent method.”

#[Override]
public function handleRequest() { ... }

If the parent method is renamed or removed (or if you have a typo), PHP will complain, throwing a fatal error at compile time. This turns another quiet mistake into a loud one, allowing you to catch inheritance bugs instantly during development. As of PHP 8.5, this attribute can now be applied to properties, not just methods.

New Array Helpers (PHP 8.4)

For 25 years, PHP developers have struggled to remember the specific invocation for reset(), end(), or array_shift() just to get the first or last item of an array. Is it passed by reference? Does it modify the array? I’ve been writing PHP for decades, and I still have to look it up!

PHP 8.4 introduces clear, descriptive helper functions: array_find, array_first, array_last, and array_any. While it’s easy to dismiss these changes as “syntactic sugar,” I like to think of it as the kind of sugar you get from fresh fruit, not the artificial stuff in a Diet Coke! It makes the code inherently more readable and reduces the cognitive load required to understand what an array operation is actually doing.

The Pipe Operator: Cleaning Up the “Inside-Out” Read

We have all written code that looks like this:

$result  =  str_shuffle(strtoupper(trim($input)));

To understand what is happening here, your brain has to work backwards. You start in the middle ($input), read out to trim, then out to strtoupper, and finally to str_shuffle. It is “inside-out” logic. We often try to fix this by putting each function on a new line, but then you are reading right-to-left and bottom-to-top.

PHP 8.5 introduces the Pipe Operator (|>), which allows us to structure this sequentially:

$result = $input
    |> trim(...)
    |> strtoupper(...)
    |> str_shuffle(...);

Now, the code flows from top to bottom, left to right – exactly how we read text.

The “How” of Upgrading: Bridging the Gap

I realise many of you might be reading this thinking, “This looks great, Paul, but my production server is still running PHP 7.4, and there is no upgrade in sight. How will I ever get to use any of this new stuff in my app?”

The good news is that you can still use many of these features today via Polyfills. The Symfony team maintains a robust set of polyfills that backport modern PHP functions and classes to older versions. For example, if you want to use the new array_first() function but you are on PHP 8.0, you can install the polyfill. It checks if the function exists natively; if not, it provides a PHP userland implementation.

This allows you to write “future-proof” code right now. When your server eventually upgrades to the latest version, the polyfill steps aside, and your code uses the native, optimised implementation automatically. It is a seamless way to bridge the gap and start modernising your codebase incrementally without waiting for a massive infrastructure overhaul.

Conclusion: The Evolution of a Language

It is easy to look at features like Property Hooks (inspired by C#) or the Pipe Operator (common in F# and Elixir) and think that PHP is losing its identity. But the opposite is true.

Think of the English language. It famously borrows vocabulary from other languages. “Kindergarten” is German. “Government” is French. “Rodeo” is Spanish. English didn’t lose its identity by adopting these words; it became richer and more expressive by integrating concepts that worked well elsewhere.

PHP is doing the exact same thing. It is a mature, pragmatic language. It isn’t dogmatic. It observes what works well in the broader ecosystem – whether that’s immutability, type safety, or ergonomic syntax – and it adopts those features with a distinctly PHP flavor.

Modernising your legacy application is about embracing this evolution. It doesn’t require a “stop the world” rewrite. It happens in the small moments: replacing a list of constants with an Enum, adding readonly to a DTO, or swapping a fragile switch for a robust match expression.

These features – Enums, Readonly, Named Arguments, Match, Property Hooks, and Attributes – are just a small set of the goodies made available in recent PHP releases, but they provide clearer intent, safer defaults, and expressive data. They turn quiet, insidious bugs into loud, fixable errors. They reduce boilerplate and let us focus on the business logic that actually matters.

So, here is the challenge: Pick one feature from this article. In your next pull request, try to replace a set of constants with an Enum, or use named arguments in a confusing function call. Start small. You will find that these small upgrades accumulate into big quality-of-life wins for you and your team.

Enjoy a better app, and an easier life!

The post Modern PHP Features You’re Probably Not Using (But Should Be) appeared first on International PHP Conference.

]]>
30 Years of PHP, 25 Years of Testing https://phpconference.com/blog/30-years-php-25-years-testing/ Wed, 12 Nov 2025 08:38:32 +0000 https://phpconference.com/?p=107617 At the International PHP Conference Munich 2025, one of the standout sessions came from Sebastian Bergmann, co-founder and Principal Consultant at The PHP Consulting Company (thePHP.cc). Renowned as the creator of PHPUnit and a member of The PHP Foundation’s board, Sebastian has played a pivotal role in shaping the professionalisation of PHP development over the past decades.

The post 30 Years of PHP, 25 Years of Testing appeared first on International PHP Conference.

]]>

In this insightful talk, Sebastian takes us on a journey through 30 years of PHP and 25 years of testing, tracing the evolution of both the language and its ecosystem. From PHP’s early, humble beginnings to its status as a powerhouse of web development, and from the birth of PHPUnit to its place as an industry-standard testing framework, this session offers a unique perspective from someone who helped define these milestones.

IPC NEWSLETTER

All news about PHP and web development

[mc4wp-simple-turnstile]

 

💡 Key Learnings and Insights

1. The Evolution and Maturity of PHP and Its Ecosystem

Sebastian’s retrospective illustrates how PHP has transformed over 30 years from a small scripting tool into a robust, high-performance programming language with a vibrant ecosystem.
For attendees, the key takeaway is that continuous improvement and community-driven innovation are the foundation of PHP’s longevity. From major performance leaps in PHP 7 to the structured annual release cadence since PHP 5.4, every milestone underscores the value of iterative progress. The establishment of The PHP Foundation and its support for core development shows how open source can evolve into a professionally managed, sustainable ecosystem.

2. The Importance and Impact of Testing in PHP Development

With 25 years of PHPUnit shaping PHP’s testing culture, Sebastian reinforces that automated testing is not just a best practice — it’s essential for professional software development.
Modern PHP developers can now leverage a powerful suite of tools — from PHPStan for static analysis to Infection for mutation testing — to achieve higher code quality and reliability. The learning for attendees is clear: testing enables confidence, and investing in testing tools and practices pays dividends in maintainability and scalability.

3. Community Collaboration and Sponsorship as Pillars of Sustainability

 A central message of the session is that the PHP ecosystem thrives because of its collaborative and supportive community. Open dialogue through RFCs, transparent governance, and active sponsorship are what keep the language secure, relevant, and evolving.
Sebastian urges developers to contribute back — through code, documentation, funding, or advocacy — to ensure the long-term health of PHP’s infrastructure and open source projects. The lesson: the strength of PHP lies in its people as much as in its code.

YOU LOVE PHP?

Explore the PHP Core Track

 

🎥 Watch the full session recording below to gain inspiration from Sebastian’s journey and take away lessons on how testing, collaboration, and continuous improvement can shape the future of PHP — and your own development practice.

 

The post 30 Years of PHP, 25 Years of Testing appeared first on International PHP Conference.

]]>
PHP 8.5 Features: Pipe Operator, Smarter Cloning & URL Handling Explained https://phpconference.com/blog/php-8-5-new-features-pipe-operator-clone-url/ Wed, 05 Nov 2025 12:12:11 +0000 https://phpconference.com/?p=107608 PHP 8.5 promises smarter, safer, and more expressive ways to code. With powerful features like enhanced closures, the pipe operator, smarter cloning, and standard-compliant URL handling, developers can write cleaner and more robust applications. Derick Rethans, PHP internals expert and developer at the PHP Foundation, guides us through the most significant changes and shows how these improvements can directly benefit your projects.

The post PHP 8.5 Features: Pipe Operator, Smarter Cloning & URL Handling Explained appeared first on International PHP Conference.

]]>
In this article, we’ll explore what’s new in PHP 8.5. We’ll focus on some of the bigger changes, including closures in constant expressions, changes to the clone keyword, and URL parsing. Then, I’ll provide a summary of the smaller features in the language, with examples and explanations of the latest improvements. Let’s dive straight in.

YOU LOVE PHP?

Explore the PHP Core Track

 

PHP 8.5: Closures in Constant Expressions

The first thing that we’re going to look at is closures in constant expressions. It was never possible to provide a default value in an array that defines a set of closures, that you then can call in order. With PHP 8.5, it is now possible to define such a list, as seen in the example below:

<?php
function slugger(
    string $input,
    array $callbacks = [
        static function ($value) { return \strtolower($value); },
        static function ($value) { return \preg_replace('/[^a-z]/', '-', $value); },
        static function ($value) { return \trim($value, '-'); },
        static function ($value) { return \preg_replace('/-+/', '-', $value); },
    ]
) {
    foreach ($callbacks as $callback) {
        $input = $callback($input);
    }
    return $input;
}
?>

In this example, the default value of the $callbacks array contains four closures, that the foreach loop then loops over to call. A user of this function can also provide their own array of callbacks.

There are some restrictions here, because these need to be static calls. That means they can’t be methods called on objects using $this. They also cannot use any values from outside the scope that these are defined in. That means that you can’t use “use” here, or short closures starting with fn().

IPC NEWSLETTER

All news about PHP and web development

[mc4wp-simple-turnstile]

 

In addition to this, it is now also possible to use first-class callables. A first-class callable is just a form of closure, and these can also never have any information coming in from the outside scope, like in this example:

<?php
function slugger(
    string $input,
    array $callbacks = [
        \strtolower(...),
        static function ($value) { return \preg_replace('/[^a-z]/', '-', $value); },
    ]
) {
    foreach ($callbacks as $callback) {
        $input = $callback($input);
    }
    return $input;
}
?>

Here we have replaced the static function ($value) { return \strtolower($value); } call to \strtolower(…). This is still quite a clunky way of creating such a function, where your $input is transformed through multiple function calls. To alleviate this, PHP 8.5 also introduces a new feature to resolve all of this: the new pipe operator (|>).

The pipe operator is a way of chaining methods together, called in order, with a value passed along between them. You can then also compose some interesting functions. With this, we can rewrite our slugger method to:

<?php
function slugger(string $input)
{
    return $input
        |> \strtolower(...)
        |> (fn($x) => \preg_replace('/[^a-z]/', '-', $x))
        |> (fn($x) => \trim($x, '-'))
        |> (fn($x) => \preg_replace('/-+/', '-', $x));
}
?>

Like in the earlier examples, the slugger method takes the $input string, and then passes the input to strtolower(…), a short closure. Afterwards, it passes the result value of thhat to preg_replace(), trim(), and preg_replace() again.

However, because these functions take more than one argument, you can’t directly use the first class callable here. Pipes can only pass one value to the next call in the pipeline.

EVERYTHING IS CONNECTED TO THE INTERNET

Explore the Web Development Track

 

At the moment, to go around that, you have to wrap a short closure around the functions that would normally take more than one argument. The whole closure definition should also be wrapped again in parenthesis to avoid issues with priorities in the PHP code parser. That is why the example uses:

(fn($x) => \preg_replace('/[^a-z]/', '-', $x)).

This allows you to pre-define the other arguments to these functions. It uses the pipe operator that pipes the left-hand side to the closure, defined with fn($x) here, which then gets passed by the pipe operator to the third argument of the preg_replace() call as $x too.

Maybe in PHP 8.6 or later, there will be a better way of doing this, through a newly suggested feature called Partially Applied Functions.

With the pipe operator you have no insight to the value that gets passed from function to function or closure. This makes debugging a lot harder at first sight. With the first implementation, there was no way to get to this value, but through some changes in PHP, it is now possible for debuggers like Xdebug to see and present the intermediate stages of the pipe chain without you having to assign the intermediate value to a variable.

Clone With in PHP 8.5

The next feature we’re going to look at is changes to the clone keyword. For a while, PHP has had read-only and final classes, which tend to be used as value objects to be passed around. Value objects are meant to be read-only and unmutable, but sometimes you might want to update these value objects to replace certain properties with new values.

Up until now, you couldn’t really do that without resulting to a hack by creating a wither method like:

<?php
final readonly class Response {
    public function __construct(
        public int $statusCode,
        public string $reasonPhrase,
    ) {}
 
    public function withStatus($code, $reasonPhrase = ''): Response
    {
        $values = get_object_vars($this);
        $values['statusCode'] = $code;
        $values['reasonPhrase'] = $reasonPhrase;
        return new self(
            ...$values
        );
    }
}
?>

This only works in some situations, because this style of implementation relies on all the arguments being settable ad named arguments via the constructor when new self(…$values) is called.

The PHP development team originally wanted to create a specific new syntax addition to clone, to allow a new value object to be created with some properties modified. But a totally new syntax would complicate matters, as users, static analysis tools, and other tools would have to support it. Instead of a new dedicated syntax, the PHP developers have changed the clone keyword into a language construct/function hybrid.

This is needed, because up to now, a clone was a language construct only. This means that it was not possible to have arguments, as language constructs in PHP don’t really support that. It can only have a single expression as its right-hand-value.

The new feature in PHP 8.5 extends clone to make it into a function, which accepts two arguments. The first one being the object to clone, and the second one an array of property names and their new values:

<?php
final readonly class Response {
    public function __construct(
        public int $statusCode,
        public string $reasonPhrase,
    ) {}
 
    public function withStatus($code, $reasonPhrase = ''): Response
    {
        return clone($this, [
            "statusCode" => $code,
            "reasonPhrase" => $reasonPhrase,
        ]);
    }
}
?>

The withStatus() method here accepts a $code argument, and an optional $reasonPhrase argument. The clone first creates a new Response object, with all the properties set to the values of the original object.

For each of the elements in the array passed as second argument, their values are going to be set on each property with the same name (statusCode and reasonPhrase), and in the same order as how they are present in the array.

Because these are internally just a normal assignment operation, it also means that each internal assignment will follow all the requirements for the values of these properties, including type checks, and visibility checks. Property hooks and __set methods are also called as with normal assignments. The only restriction that is lifted, is the “write-once” property of readonly properties.

URL Parsing in PHP 8.5

The third big feature that we’re going to look at is URL parsing. For a long time, PHP has had the parse_url() function, which takes a URL or URI and parses this into its components. However, this function doesn’t follow any standard, has some strange PHP-isms while parsing the URL, and in general isn’t very useful for parsing URLs according to any standard, or using them safely in the modern web.

PHP 8.5 improves on this situation by introducing two new classes to parse, represent, and modify URLs. Each of the two variants is slightly different because they follow a slightly different standard. You can construct either of these by using “new”, but there is also a static parse() method. The constructor approach will throw an Uri\InvalidUriException when it encounters an invalid URI. The parse() factory method does not do this, and instead returns null.

The first one that we introduced is the Uri\WhatWg\Url class. Both the constructor and the parse() factory method parse the URL according to the WhatWG standard. Once parsed, you can access each of the component parts, create a new object with a component in the URL changed through a wither method, and then retrieve a fully assembled URL as a string again.

This class is best used if you need to do something with URLs that you’re going to embed into HTML. For example, when you regenerate URLs in a CMS, etc. Beyond the WhatWgUrl class, there is also the \Uri\Rfc3986\Url class. This parses the URL according to slightly different standards, in this case the RFC3986 standard.

YOU LOVE PHP?

Explore the PHP Core Track

 

This kind of URL is mostly used for server-to-server communication. Think of it as DSN parsing or outgoing HTTP requests that you make yourself. Both classes implement very similar methods that are not quite the same, because the concepts for each of these two different URL types are distinct.

Let’s have a look at our first one. In this example, we’re showing how to use the new Uri\WhatWg\Url class to parse our example URL. With the methods getScheme(), getAsciiHost(), getPath(), getQuery() and getFragment(), we can then get access to the original constituent parts:

<?php
// Parse URL:
$url = new \Uri\WhatWg\Url('https://friday-night-dinners.co.uk/archive/?search=local#artean');

// Show components:
echo $url->getScheme(), "\n";
echo $url->getAsciiHost(), "\n";
echo $url->getPath(), "\n";
echo $url->getQuery(), "\n";
echo $url->getFragment(), "\n";
?>

This outputs:

https
friday-night-dinners.co.uk
/archive
search=local
artean

It is also possible to modify these parts by calling wither methods as well. We continue from the previous example with:

<?php
$newUrl =
    $url->withPath('/latest')
        ->withQuery('search=spanish')
        ->withFragment('');
?>

Please note that you need to assign the result from the wither methods to a new variable. The object is immutable and a new object will be returned from each of these methods. With the URL modified, we can finally convert it back to a full string:

<?php
echo $newUrl->toAsciiString();
?>

Which then outputs:

https://friday-night-dinners.co.uk/latest?search=spanish

Both the WhatWg\Url and Rfc3986\Url classes will know how to adapt the specific components according to the respective specification correctly. This also ensures that the strings that WhatWg\Url::toAsciiString() method, and its counterpart Rfc3986\Url::toString(), produce, are correctly formed as well.

Other Features in PHP 8.5

Now let’s see some of the smaller features that have been added in PHP 8.5.

Final Constructor Property Promotions

Constructor property promotions were introduced in PHP 8.1. These allow you to specify the visibility of a typed property inside the constructor’s argument definition, instead of having to define them separately, and then do the assignments from arguments to these properties manually in the constructor.

In PHP 8.4, we introduced property hooks that allow you to run some code when a property is being get or set with a user-defined function. With the inclusion of this, PHP also gained final properties, but these properties were not allowed to be defined in a constructor for property promotion.

PHP 8.5 now adds this functionality, as you can see in the following example:

<?php
class User
{
    public function __construct(
        final private string $first,
        final private string $last,
    ) {}

    final public string $fullName {
        get => $this->first . " " . $this->last;
        set { [$this->first, $this->last] = explode(' ', $value); }
    }
}

$u = new User("Derek", "Rethans");
$u->fullName = "Derick Rethans";
echo $u->fullName, "\n";
?>

Extending the User class and redefining the type of the final private string properties $first and $last are now prohibited.

The #[noDiscard] Attribute

This new attribute enforced that during run time, the calling function consumes the returned value (by assignment, or it being passed on to another function as argument).

For example, if you have a DateTimeImmutable class and call the setDate method on it, you also will have to assign it to a new variable, otherwise the modification disappears. This is because DateTimeImmutable’s set methods return a new object and don’t modify the original one. Just like the two URL classes from earlier through their wither methods.

In PHP 8.5, the methods on the DateTimeImmutable class that return a new object now have this new NoDiscard attribute attached to them. When you don’t assign the return value to a new variable, you will get a run-time warning, like in this example:

<?php
$dt = new DateTimeImmutable();
$dt->setTime(9, 45);
?>

It will show you this warning to hint that you need to assign the newly created object to a variable:

Warning: The return value of method DateTimeImmutable::setTime() should either be used or intentionally ignored by casting it as (void), as DateTimeImmutable::setTime() does not modify the object itself.

As the message indicates, you can ignore the returned value by using the (void) cast, but at least with DateTimeImmutable, this makes no sense. You can use the #[NoDiscard] attribute in code that you write as well. It is an additional helper to make sure that you, or your library’s users, are not making mistakes in their code.

Note: The new WhatWg\Url and Rfc3986\Url classes have with* methods. This naming convention already signals that these return a new object, which is not something that is apparent with the set*-named methods from the DateTimeImmutable class. Because of this, the Url classes do not have the #[NoDiscard] attribute attached to them at the time of writing.

IPC NEWSLETTER

All news about PHP and web development

[mc4wp-simple-turnstile]

 

Filter Extension

The filter extension has a new mode for when you validate incoming request variables. Normally, the filter_var() function would return false if it couldn’t validate the value. With an option flag to filter_var() you can make it instead return null if the input variable didn’t match with what you expected.

There is already a flag, FILTER_NULL_ON_FAILURE to make it return null in these situations, instead of false. This is useful because some probably values indeed run boolean false as a valid value. However, even with the FILTER_NULL_ON_FAILURE flag enabled, it makes for interesting and complex code. Instead, it is much better to be able to catch an exception.

PHP 8.5 introduces the FILTER_THROW_ON_FAILURE mode for filter_var(), which means that if an error is encountered while filtering the value to make sure it is correct, it will throw an exception, which you can then catch and handle in one go.

As you can see in this example here:

<?php
function validateUser(string $email, string $userId, string $userName) : bool
{
    try {
        filter_var($email, FILTER_VALIDATE_EMAIL, FILTER_THROW_ON_FAILURE);
        filter_var($userId, FILTER_VALIDATE_INT, FILTER_THROW_ON_FAILURE);
        filter_var(
            $userName, FILTER_VALIDATE_REGEXP,
            ['options' => ['regexp' => '/^[a-z]+$/'], 'flags' => FILTER_THROW_ON_FAILURE]
        );
        return true;
    } catch (\Filter\FilterFailedException $e) {
        return false;
    }
}
?>

array_first() and array_last()

PHP 7.3 introduced the array_key_first() and array_key_last() functions, to get either the first or the last key from an array. At that time, we didn’t introduce any functions to obtain the first and last array element values, because we weren’t quite sure whether we needed that.

However, it has now become clear that these are actually useful. This is why in PHP 8.5, we now have two new functions: array_first() and array_last(). These respectively return the first or last element values from an array, as I show you in this example here:

<?php
$timezone = new DateTimeZone("Europe/Kyiv");
$trans = $timezone->getTransitions();
var_dump(array_first($trans), array_last($trans));
?>

OPcache Built-In

The last new change in PHP 8.5 that I want to focus on is that the OPcache extension can no longer be disabled. It is no longer a shared extension that you need to load specifically into PHP, and instead, it is built-in as a static extension like ext/standard or ext/date.

Although it is built-in, it does not mean that OPcache is also enabled by default. You still need to make the correct configuration settings to do so. Due to this tighter coupling, the PHP development team has now more freedom to utilise features in OPcache, such as its optimiser, in a more coherent fashion. It likely opens up avenues to improve performance, which is something that the PHP development team can now investigate.

Conclusion

The new features as presented here, are a high level overview of some of the bigger improvements and additions.

To see the full list, please visit our release page at https://www.php.net/releases/8.5/en.php, and the full change log at https://www.php.net/ChangeLog-8.php#PHP_8_5.

The post PHP 8.5 Features: Pipe Operator, Smarter Cloning & URL Handling Explained appeared first on International PHP Conference.

]]>
How to Safely Upgrade Legacy PHP Applications to PHP 8 https://phpconference.com/blog/upgrade-legacy-php-applications-php8/ Mon, 29 Sep 2025 15:08:13 +0000 https://phpconference.com/?p=107568 “If it ain’t broke, don’t fix it” is a principle that often leads people to think that an application can be left alone and won’t break by itself. That’s a misguided notion. As time passes, new PHP versions are released and the old ones eventually stop receiving security patches. This means that as new vulnerabilities get discovered in old PHP versions, they just stay vulnerable. By extension, applications running on these PHP versions become vulnerable over time. They become broken. Therefore, they need fixing.

The post How to Safely Upgrade Legacy PHP Applications to PHP 8 appeared first on International PHP Conference.

]]>
In this article, I will explain the methodology which allowed me to safely upgrade hundreds of PHP applications to the latest PHP version. The majority of those were one or several major PHP versions behind. I will also mention many common caveats so you can avoid them.

IPC NEWSLETTER

All news about PHP and web development

[mc4wp-simple-turnstile]

 

Does it Work on PHP 8?

Unless you have automated and thorough black box tests, you can’t tell for certain. Unit tests won’t be of much help because they or the testing framework might also not be compatible with the latest PHP. If you change both the tests and the system under test at the same time, regressions can slip through the cracks. The code needs to be exercised, whether manually or through a new black box test suite. I will discuss testing strategies a bit later in the article. For now, I will focus on some of the tools I use to help me with finding and understanding PHP compatibility issues.

The first tool has a self-explanatory name: PHPCompatibility. It’s open source, is mostly maintained by Juliette Reinders Folmer, and is currently in need of funding. It can detect a variety of compatibility issues, such as removed extensions, using class names that became reserved, forbidden call-time pass by reference, etc. Once installed by following the README, it can be executed on the command-line like this:

phpcs . --standard=PHPCompatibility --runtime-set testVersion 8.4

This will scan your current directory for compatibility issues with PHP 8.4. You can target other versions, so you could, for example, upgrade to PHP 7.4 before jumping to PHP 8.4. You could add more flags and options to the above command, such as -p to display progress, –colors to display colors in the output, and –extensions=php,inc,phtml to filter the files analyzed. The result looks like this:

FILE: /www/src/app/controllers/MyController.php
------------------------------------------------------------------
FOUND 1 ERROR AFFECTING 1 LINE
------------------------------------------------------------------
 166 | ERROR | Using 'break' outside of a loop or switch structure
     |       | is invalid and will throw a fatal error since PHP
     |       | 7.0
------------------------------------------------------------------

For some applications, this will be a very long list and will require much research to fix. Even then, it doesn’t find every possible issue. This is because PHPCompatibility doesn’t have cross-file awareness and doesn’t infer types. Even then, because the legacy code likely doesn’t have strict type declarations, you can’t detect errors until you get to the faulty scenario at runtime. In PHP 8, this represents a large portion of all issues because of the type strictness that it introduced. For example:

  • Calling count() on non-countable used to be a warning and is now a fatal error.
  • Stricter loose comparisons, making expressions such as ‘php’ == 0 return false instead of true.
  • Most functions are stricter on their inputs, such as fopen() no longer accepting nulls or empty strings. In fact, fopen() can no longer return false, which is problematic with code that relies on that behavior.

YOU LOVE PHP?

Explore the PHP Core Track

 

Fatal errors, although frustrating, are still preferred to unexpected logic changes, which are harder to catch and can lead to severe effects. It can also require much effort to understand how all these changes impact a given piece of code, since nobody might know what it’s supposed to do in the first place. This is why I usually avoid indiscriminately casting values before passing them to a PHP function, as it hides the real issue or completely changes the behavior. Example:

$string = array();
- $lowercase = strtolower($string);
+ $lowercase = strtolower((string) $string);

Although this change prevents PHP 8 from emitting a fatal error, it also changes the result from null to “array”, which can take the execution down a completely different path, potentially causing destructive actions such as overwriting data with this new string.

Static Analysis

Tools such as PHPStan, Psalm, and Phan can detect some of the same things that PHPCompatibility can, but they are less focused on compatibility. However, they can complement PHPCompatibility with their ability to infer types. Here is an example of issues that PHPStan can detect, which can signal potential runtime issues on PHP 8:

  • Undefined variables. These would only emit warnings in PHP 7, but fatal errors in PHP 8.
  • Static calls to instance methods. These would get promoted from deprecation warnings to fatal errors in PHP 8.

These additional insights are very helpful, as give you a list that can serve as a basis for planning the upgrade project. However, PHPStan won’t be usable on all codebases, especially if it’s written in PHP 5, doesn’t use PSR-4, or has a lot of dead code. It will complain about pre-existing errors, even if those are false positives or inside dead code, and refuse to perform the full scan until you eliminate them. In large projects or in the early stages of an upgrade, eliminating all these issues might not be practical. PHPStan prioritizes preventing bugs over documenting them. Don’t be discouraged if this happens in your project. You can instead activate all warnings and notices on the original PHP version and exercise the code via a test suite, which I’ll discuss in the next part. The execution of the tests will generate logs. You can then research whether the warning or notice becomes a fatal error in PHP 8 and make a list that way.

Rector is a refactoring tool that I use when I need a very specific refactoring rule. In the PHP versions category, its main focus is on introducing modern features, which isn’t a priority if I want to get off an unsupported PHP version quickly. It also can be incorrect or incomplete, so it should be used carefully. For example, when replacing PHP 4 style constructors, it renames the method but doesn’t update the constructor calls. PHPStorm does this correctly, and I use PHPStorm to fix PHP 4 style constructors. Here are some examples of how I use Rector:

  • Swap the order of implode() arguments. Both orders were accepted in the past.
  • Add a missing parameter to a method based on the parent’s method.
  • Create a custom rule to quickly fix a multitude of similar issues. Example: replace static calls with an instantiation, in the case of the static calls to instance methods issue mentioned above. Be aware that writing custom Rector rules has a steep learning curve.

Some of the previously discussed issues can be detected and fixed by PHPStorm, which is a commercial product, but widespread enough to mention here. It has a multitude of useful inspections and quick-fixes, but not enough to replace the previous tools. It does have a Replace Structurally feature, which allows us to create simple yet syntax-aware replacements. For example, say I wanted to write a compatibility adapter for the fopen() function, but only when it’s called with 2 arguments. I would be able to search for all references to this function with exactly 2 arguments, which is not something one should attempt with regular expressions because of the potential complexity. Example: fopen((new MyClass($array[‘key’]))->getPath(), ‘r’). PHPStorm does the heavy lifting here with fopen($arguments$). I then tell it to replace it with Compatibility::fopen($arguments$). This allows me to make safe changes that don’t accidentally erase portions of the code or introduce parsing errors.

As you can see, every tool has its advantages and drawbacks, so you need to find how to best combine all these tools for your specific upgrade project. Even with all these tools, you still need to thoroughly test your code to ensure that the behavior didn’t change.

 

Testing

If the application doesn’t yet have a complete black box test suite, I recommend writing characterization tests. These will ensure that the application continues to behave the same way as on the old PHP version. For this, you would write tests that pass on the old PHP version with the unchanged codebase. Once you put the application on a new PHP version, the same tests will obviously fail. You would then combine the insights from the previously discussed tools, logs, and the newly created tests to fix the compatibility issues until the tests pass. The more thorough the automated test suite, the fewer manual tests you would need.

There are many tools to accomplish this, although I personally use Cypress due to the community size, abundance of plugins, ease of use, and great documentation. Installation instructions and tutorials are available on their website. The tests can run in the browser, which is useful for debugging, or headless on the command-line, which is useful to put in a continuous integration pipeline. Test cases will be written in JavaScript or TypeScript. Here is an example of a test (Listing 1).

describe('Checkout', () => {
    it('Can add items to the shopping cart', () => {
        const productName = 'Product 1';
        
        cy.visit('/shop')

        cy.contains('.product', productName)
            .siblings('div')
            .contains('button', 'Add to Cart')
            .click();

        cy.title().should('eq', 'My Cart');
        cy.contains('.cart-item', productName).should('exist');
  })
})

This test opens the shop, finds a specific product, finds and clicks the associated Add to Cart button, then ensures we end up in the cart with that product added to it. One advantage of these tests is that they won’t need to be changed even if you replace most of your libraries. In fact, you could even rewrite your entire application in a completely different language, although I don’t recommend it in most cases. In 23 years, I only recommended a rewrite twice, both times because the language was dead. PHP is very much alive, so it’s safer and less expensive to upgrade the code.

Another type of regressions you should look out for is performance. Some compatibility solutions might be more resource-intensive. Black box tests can measure little beyond the response time, but the application can be modified to inject performance metrics into the page whenever it detects a test environment. These changes need to be done starting with the original code so that the current performance can be captured. The captured metrics can then be added into the tests’ expectations. Example:

window.phpPerformance = {
    memoryUsage: <?php echo $memoryUsage; ?>
};

Let’s say that the current application reports 20MB, and we want the new version to not exceed this value. Here’s an example assertion in a Cypress test:

cy.window().then((win) => {
    const megabyte = 1024 * 1024;
    expect(win.phpPerformance.memoryUsage)
    .to.be.at.most(20 * megabyte);
});

Limiting Behavior Regressions

The approach I privilege in PHP upgrades is one where I make minimal changes to individual expressions. Expressions are smaller than statements. For example:

  • $object->property
  • $a + 1
  • number_format($a + 1)

Some expressions can be affected when moving to a new PHP version. It’s much easier to reason about an expression than it is about the state of an entire application, possibly across multiple HTTP requests. State can get extremely complex, especially if it doesn’t follow best practices and abuses globals, which is quite typical of the legacy applications I work with. If an individual expression, given the same values, exhibits the same behavior, then by extension, the entire application should continue to behave the same. With that in mind, I don’t need to understand each one of the millions of lines of code and how they interact. I reduce the application to its most basic elements and fix those.

Let’s take $object->property = ‘php’. It seems simple enough until the object is undefined. In PHP 8, this results in a fatal error. In PHP 4 through 7, it magically instantiates the object in that scope before assigning. If you’re lucky, you can initialize the variable just before. But what if it’s passed to the function, and you can no longer say with certainty whether it can be null? In a codebase which relied heavily on this magic behavior, I created a custom Rector rule to find all property assignments where the target object is not declared in the same scope. I then replaced those with Compatibility::initObject($object)->property = ‘php’, where the new method would check the object’s value at runtime and instantiate it if needed, making this expression retain its old behavior.

IPC NEWSLETTER

All news about PHP and web development

[mc4wp-simple-turnstile]

 

Native PHP functions became stricter in PHP 8. They would reject invalid types and values. In the past, such an input would typically return null or false, depending on the input. For example, in PHP 7, mb_strtolower() would return null if no arguments are provided, but false given an invalid encoding. Many PHP 8 functions are no longer capable of returning null or false. This is why most of my compatibility adapters check for these scenarios and return these values before calling the native function (Listing 2).

public static function mb_strtolower($string = null, $encoding = 'ISO-8859-1'): string|null|false
{
    if (func_num_args() === 0) {
        return null;
    }
    
    if (self::isValidEncoding($encoding) === false) {
        return false;
    }
    
    return \mb_strtolower((string)$string, $encoding);
}

Can a full compatibility library be created for legacy PHP? Perhaps, but that would cause performance degradation. All the logic that you see above the native function used to be inside the native function, which was written in C. If we reproduce every single scenario that the function used to have, but in PHP, it would add significant overhead. Instead, I recommend focusing only on functions and scenarios that affect your code. If you never pass invalid encodings to this function, then there’s no point in validating it in this adapter. You can check whether a function can receive invalid input by logging the types and/or values at the top of the adapter, run your test suite, and analyze the log to understand how the application uses this function.

Most of these solutions make the code less pretty. However, the aim is to make safe changes and enable us to modify the compatibility adapters if we discover a new scenario that we didn’t account for, instead of having to undo all our changes inline. It makes the fix more maintainable. The goal, once the pressure to get off an unsupported PHP version is gone, is to refactor to clean the code so it doesn’t need these fixes in the first place.

 

A tool that I really like to probe functions and experiment with solutions is 3v4l, an online shell to run PHP code on multiple PHP versions. It makes it easy to compare outputs. I would, for example, supply all kinds of invalid input to a function and compare the outputs across versions. This tells me what scenarios I may need to put in the adapter. I can also write an adapter and test it there. Once satisfied with the adapter, I would write unit tests for it. I can, of course, achieve this locally by running multiple PHP versions, but I like the ability to then share those code snippets with my colleagues and on social media.

Third-Party Code

I see many developers disregard third-party code because they expect to simply replace it with the latest community version. That might not be possible or practical if, for example:

  • There are hardcoded overrides, so your version of the library no longer has the same behavior as the community version.
  • The latest community version has a different behavior. Remember the complete overhaul of Doctrine between versions 1 and 2. Even subtle differences can significantly affect the application that uses it. These differences are often undocumented, that is, if the documentation still exists, which is becoming increasingly an issue as well.
  • The community project was abandoned, so there is no replacement compatible with PHP 8.

Once you make a list of all your dependencies, you need to determine the feasibility of using the latest community version. In some cases, you may find community-maintained forks of the legacy library or framework, which both preserves the old behavior and runs on the latest PHP. An example of that would be zf1-future, which is compatible with PHP 8.1, and is a close enough starting point. Such forks are common, especially for widely used libraries, since so much other legacy code depends on them. Remember to dig a bit deeper if your code uses an abandoned library.

It’s also possible to replace abandoned libraries with different ones or even develop your own. This might be an opportunity to have something that better satisfies today’s needs. An example of that might be replacing pChart 1.x, since version 2 was a complete rewrite, so your code won’t work with it. For example, if the purpose was to render charts in the browser, then a JavaScript library like Chart.js or Google Charts might work even better than PHP code generating static images.

Another type of third-party code we didn’t discuss yet is extensions. I reason about them in the same way I reason about PHP libraries. Does it exist for the latest PHP version? Does it behave the same? Are there replacements, either as extensions or as Composer packages? Even when you do find a replacement, make sure it behaves the same as in the legacy PHP version. For example mcrypt_compat is a Composer package that replaces the abandoned mcrypt extension. However, mcrypt, before PHP 5.6, would accept a shorter key and would simply pad it with ‘\0’ to get the required size. Starting in PHP 5.6, it would instead reject the shorter key and return false. For a codebase that has already encrypted everything using the zero-padded key, that would be problematic. To fix it, I got the author to add a PHPSECLIB_MCRYPT_TARGET_VERSION constant to allow this package to replicate the old behavior.

After the upgrade, it is still a good idea to address the underlying issue of using a key that’s too short, as it undermines security, but this approach gives us more granular control for a progressive modernization journey. It’s always better to have things a bit more secure now than wait for everything to be ready later.

YOU LOVE PHP?

Explore the PHP Core Track

 

Conclusion

Here is what I would like you to take away from this article:

  • Code becomes broken over time, as libraries get abandoned and old PHP versions stop getting security patches.
  • Knowing the advantages and limitations of each tool will help you combine them in the best way for your upgrade.
  • Focus on preserving existing behavior. You can refactor later once you’re off the old PHP version.
  • To avoid regressions, create characterization tests and limit changes to individual expressions when possible.
  • Make a list of third-party dependencies, research their state and alternatives, and make a decision on a case-by-case basis.
  • Don’t be afraid to ask authors of your favorite tools and libraries about your specific scenario. You can also sponsor their work.

I hope this advice helps you with your next PHP upgrade project. Happy coding!


Frequently Asked Questions (FAQ)

1. Why should legacy PHP applications be upgraded?

Old PHP versions stop receiving security patches, making applications running on them vulnerable over time. Upgrading is necessary to maintain security and functionality.

2. What is the PHPCompatibility tool and how does it help?

PHPCompatibility is an open-source tool that scans PHP code for compatibility issues with specific PHP versions. It detects removed extensions, reserved class names, and invalid syntax such as using break outside of loops.

3. What are the limitations of PHPCompatibility?

PHPCompatibility lacks cross-file awareness and type inference, so it may miss issues related to PHP 8’s stricter type system. Errors often remain undetected until runtime.

4. How can static analysis tools like PHPStan help in upgrades?

PHPStan and similar tools infer types and detect potential runtime issues such as undefined variables or static calls to instance methods. However, they may struggle with older codebases or those lacking PSR-4 structure.

5. What role does Rector play in PHP upgrades?

Rector automates refactoring tasks and helps apply PHP version-specific changes. It can apply custom transformation rules, though it requires careful configuration and validation.

6. How can testing strategies prevent regressions during upgrades?

Creating characterization tests on the old PHP version helps preserve existing behavior after upgrade. Tools like Cypress can automate these tests in browsers or CI pipelines.

7. How can performance regressions be detected in upgraded applications?

By injecting performance metrics into pages and validating them in test assertions, developers can ensure that performance does not degrade after the PHP upgrade.

8. What is the safest approach to upgrading legacy PHP expressions?

Focusing on fixing individual expressions rather than entire states reduces the risk of introducing logic errors. Compatibility adapters can replicate legacy behavior safely.

9. How should third-party dependencies be handled in a PHP upgrade?

Each dependency should be evaluated for PHP 8 compatibility. Options include using community forks, replacing with modern alternatives, or creating custom adapters.

10. Is it feasible to create a full compatibility library for legacy PHP?

While possible, a complete compatibility layer may introduce performance overhead. Developers should focus on only the functions and behaviors actively used by their application.

The post How to Safely Upgrade Legacy PHP Applications to PHP 8 appeared first on International PHP Conference.

]]>