Skip to content

01 · Advanced Framework Patterns

Laravel, Symfony, and every other mature PHP framework are built from patterns already covered in this path — dependency injection, middleware, a router — plus two more that tie an application's bootstrap together: service providers, which organize what gets registered into the container, and a lightweight ORM (object-relational mapper) built on PHP's magic methods, which is what Product::find(1)->name is doing under the hood in something like Eloquent.

Service providers: organizing container registration

A real application doesn't call $container->bind(...) inline all over the place — each feature area (mail, database, queue) gets its own service provider with two phases: register() (declare bindings — no dependency resolution yet, since other providers may not have registered theirs) and boot() (run once every provider has registered, safe to resolve things now).

<?php
// Container.php, ServiceProvider.php
declare(strict_types=1);

final class Container
{
    private array $bindings = [];
    private array $instances = [];

    public function bind(string $abstract, callable $factory): void
    {
        $this->bindings[$abstract] = $factory;
    }

    public function get(string $abstract): mixed
    {
        if (isset($this->instances[$abstract])) {
            return $this->instances[$abstract];
        }
        return $this->instances[$abstract] = ($this->bindings[$abstract])($this);
    }
}

interface ServiceProvider
{
    public function register(Container $c): void;
    public function boot(Container $c): void;
}
<?php
// MailServiceProvider.php
declare(strict_types=1);

interface Mailer { public function send(string $to): void; }

final class SmtpMailer implements Mailer
{
    public function send(string $to): void { echo "Sending mail to $to via SMTP\n"; }
}

final class MailServiceProvider implements ServiceProvider
{
    public function register(Container $c): void
    {
        $c->bind(Mailer::class, fn() => new SmtpMailer());
        echo "MailServiceProvider: registered Mailer binding\n";
    }

    public function boot(Container $c): void
    {
        // Safe to RESOLVE here -- every provider's register() has already run.
        echo "MailServiceProvider: booted (mailer ready = " . get_class($c->get(Mailer::class)) . ")\n";
    }
}
<?php
// Application.php
declare(strict_types=1);

final class Application
{
    private Container $container;
    /** @var ServiceProvider[] */
    private array $providers = [];

    public function __construct() { $this->container = new Container(); }

    public function register(ServiceProvider $p): void
    {
        $this->providers[] = $p;
        $p->register($this->container);
    }

    public function boot(): void
    {
        foreach ($this->providers as $p) {
            $p->boot($this->container);
        }
    }

    public function make(string $abstract): mixed { return $this->container->get($abstract); }
}
<?php
// demo.php
require __DIR__ . '/Application.php';
require __DIR__ . '/MailServiceProvider.php';

$app = new Application();
$app->register(new MailServiceProvider());
$app->boot();
$app->make(Mailer::class)->send('ada@example.com');
MailServiceProvider: registered Mailer binding
MailServiceProvider: booted (mailer ready = SmtpMailer)
Sending mail to ada@example.com via SMTP

The two-phase split matters the moment two providers depend on each other: QueueServiceProvider::boot() might need the Mailer binding to already exist to wire up a "send email" job handler. If registration and resolution happened in a single pass, provider order would silently determine whether the app boots — splitting into register() (declare only) then boot() (resolve freely) removes that ordering dependency entirely.

A minimal ORM: magic methods as the mechanism

$product->name reading and writing an internal $attributes array, without Product declaring a $name property at all, is what makes Eloquent/Doctrine-style models feel like plain objects. It's built on two PHP magic methods: __get() and __set(), called automatically when code touches an undeclared (or inaccessible) property.

<?php
// Model.php
declare(strict_types=1);

abstract class Model
{
    protected static PDO $pdo;
    protected static string $table;
    protected array $attributes = [];

    public static function boot(PDO $pdo): void { static::$pdo = $pdo; }

    public function __get(string $name): mixed { return $this->attributes[$name] ?? null; }
    public function __set(string $name, mixed $value): void { $this->attributes[$name] = $value; }

    public function save(): void
    {
        $cols = array_keys($this->attributes);
        $placeholders = array_map(fn($c) => ":$c", $cols);
        $sql = sprintf(
            'INSERT INTO %s (%s) VALUES (%s)',
            static::$table, implode(',', $cols), implode(',', $placeholders)
        );
        $stmt = static::$pdo->prepare($sql);
        $stmt->execute($this->attributes);
        $this->attributes['id'] = (int) static::$pdo->lastInsertId();
    }

    public static function find(int $id): ?static
    {
        $stmt = static::$pdo->prepare('SELECT * FROM ' . static::$table . ' WHERE id = :id');
        $stmt->execute(['id' => $id]);
        $row = $stmt->fetch(PDO::FETCH_ASSOC);
        if ($row === false) {
            return null;
        }
        $model = new static();
        $model->attributes = $row;
        return $model;
    }
}
<?php
// Product.php + demo
declare(strict_types=1);
require __DIR__ . '/Model.php';

final class Product extends Model
{
    protected static string $table = 'products';
}

$pdo = new PDO('sqlite::memory:');
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$pdo->exec('CREATE TABLE products (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT, price INTEGER)');
Product::boot($pdo);

$p = new Product();
$p->name = 'Keyboard';    // __set() -- no "name" property declared on Product
$p->price = 4999;
$p->save();
echo "Saved product id={$p->id}\n";   // __get()

$found = Product::find($p->id);
echo "Found: {$found->name} at {$found->price} cents\n";
Saved product id=1
Found: Keyboard at 4999 cents

static::$table and new static() (not self::) are what let find() be written once on Model and still return a Product — and every other subclass's own table — correctly. self:: would hard-code whichever class defined the method; static:: resolves against the class actually called, PHP's "late static binding."

PHP traps

Magic methods only fire for inaccessible/undeclared properties. If Product accidentally declares public string $name directly, reading $p->name uses that real property and __get() is never called — mixing declared properties with magic ones on the same model is a common source of "why isn't my accessor running" confusion. Pick one approach per class.

self:: vs. static:: is invisible until you subclass. A Model method written with self::$table would always resolve to Model's own (non-existent) $table, not Product::$table — this only breaks the moment a subclass actually calls the inherited method, which can be far from where the bug was introduced.

Service providers that resolve dependencies inside register() (instead of boot()) work by accident until provider order changes — resolving Mailer from register() before every provider has registered its own bindings is a race condition disguised as working code. Keep register() strictly to declaring bindings.

Advanced patterns cheat sheet

Pattern Purpose
Service provider Groups related container bindings + boot logic per feature
register() Declare bindings only — no resolving yet
boot() Runs after all providers registered — safe to resolve
__get() / __set() Intercept access to undeclared/inaccessible properties
static:: (late static binding) Resolves against the calling class, not the defining one
new static() Instantiate the actual subclass, not the base class

How It Actually Works

Service providers organize container registration by deferring actual object construction: a provider's register() method typically stores closures (factory recipes) in the container rather than eagerly building objects, so registering fifty services costs almost nothing until each is actually resolved — the container only invokes a given closure the first time something asks for that service, exploiting the same call-when-needed laziness as autoloading. A minimal ORM built on magic methods leans on PHP's __get/__set/__call hooks, which the engine invokes automatically whenever code accesses a property or method that doesn't exist on the object's class entry — this is a genuine fallback mechanism checked by the property/method-access opcodes themselves (ZEND_FETCH_OBJ_R and friends check for a defined property first, then fall back to __get only if the lookup fails), which is exactly why magic-method-based ORMs feel like plain property access syntactically but are secretly routing every single access through a method call, with the corresponding per-access overhead and the loss of IDE/static-analysis visibility that comes from properties that don't really exist on the class.

Exercise

Add a QueueServiceProvider that binds a Queue interface (reuse the FileQueue idea from Working with Queues conceptually, or a simple in-memory array-backed stand-in) and, in its boot(), resolves Mailer from the container and prints a line proving it can see the binding MailServiceProvider registered. Register both providers on one Application, call boot() once, and confirm the output shows MailServiceProvider and QueueServiceProvider both booting successfully regardless of registration order — register QueueServiceProvider first and confirm it still works, since boot() for all providers only runs after every register() has completed.