PHP OOP Explained Through a Real Kitchen Brigade System
Escoffier's brigade de cuisine solved the same problem PHP's class visibility solves: who gets to touch what, and who answers to whom. Part 2 of the kitchen series, and the one where we build something real.
If you haven't read Part 1, the fundamentals piece where I got read the riot act over a container of unlabeled clarified butter, go do that first. This piece assumes you're comfortable with variables, functions, and scope, because everything here builds directly on top of it, the same way a kitchen builds a hierarchy on top of cooks who already know how to hold a knife.
The brigade de cuisine system, formalized by Auguste Escoffier over a century ago, exists to answer one question: in a kitchen with fifteen people moving fast around open flame and sharp steel, who is responsible for what, and who is allowed to touch whose work. Executive chef at the top. Sous chefs under them. Chefs de partie running individual stations. Commis training under those chefs, learning technique before they're trusted with a station of their own. Nobody wanders into someone else's mise en place. Nobody skips the hierarchy and goes straight to the executive chef with a question their sous chef could answer. The structure isn't bureaucracy for its own sake. It's what makes fifteen people moving fast survive contact with a Saturday night.
PHP's object-oriented features solve the identical problem for code: who gets to touch what state, who inherits whose behavior, and how you build something a whole team can work on without everyone stepping on everyone else's mise en place.
A Class Is a Recipe Card. An Object Is Tonight's Batch.
A recipe card describes what a dish is, its ingredients, its method, its plating. It is not, itself, food. You can't eat a recipe card. What you eat is tonight's batch, made from that card, by a specific cook, with specific ingredients that were on hand tonight.
<?php
declare(strict_types=1);
class SauceStation
{
private string $baseStock;
private float $reductionRatio;
public function __construct(string $baseStock, float $reductionRatio)
{
$this->baseStock = $baseStock;
$this->reductionRatio = $reductionRatio;
}
public function reduce(): string
{
return "{$this->baseStock} reduced by a factor of {$this->reductionRatio}";
}
}
$tonightsHollandaise = new SauceStation(baseStock: 'clarified butter emulsion', reductionRatio: 1.4);
echo $tonightsHollandaise->reduce();
SauceStation is the recipe card, the class. $tonightsHollandaise is tonight's batch, the object, an actual instance built from that card with the specific ingredients handed to it at construction time. You can make a second instance right now, with a different base stock and a different ratio, and it won't affect the first one at all, the same way tonight's batch of hollandaise has nothing to do with last night's.
$tonightsBearnaise = new SauceStation(baseStock: 'white wine reduction', reductionRatio: 1.2);
Two objects, one class. That's the entire point of separating the recipe from the batch: you write the method once, and every instance gets to use it with its own state.
Visibility Is Who's Allowed Into Your Mise En Place
This is where the brigade hierarchy stops being a metaphor and starts being close to a literal translation. private, protected, and public are PHP's answer to a question every real kitchen already answers without a second thought: who's allowed to touch this.
private is your own station's mise en place, full stop. Nobody outside this exact class gets to touch it, not another chef, not even a station that inherits from yours later.
class SauceStation
{
private float $currentTemperature = 180.0;
private function adjustHeat(float $delta): void
{
$this->currentTemperature += $delta;
}
public function reduce(): string
{
$this->adjustHeat(-10.0); // Only this class can call adjustHeat directly
return "Reducing at {$this->currentTemperature}°F";
}
}
$station = new SauceStation();
echo $station->reduce(); // Works fine
// $station->adjustHeat(-10.0); // Fatal error: cannot access private method
adjustHeat is a technique this station's chef uses internally to do their job. It's not written on the menu, and nobody outside this class, not even code in the same file, gets to call it directly. That's not paranoia. That's the same reason a chef de partie doesn't let the dishwasher walk up and start adjusting their burner mid-service, no matter how confident the dishwasher feels about it.
protected is a family recipe, passed down within the lineage but kept out of the general kitchen. A chef de partie can teach it to their own commis. A completely different station doesn't get access, no matter how senior that other station's chef is.
class KitchenStation
{
protected string $stationName;
protected function callOrderReady(string $dish): string
{
return "{$this->stationName}: {$dish} up!";
}
}
class SauceStation extends KitchenStation
{
public function __construct(string $stationName)
{
$this->stationName = $stationName;
}
public function finishDish(string $dish): string
{
return $this->callOrderReady($dish); // Inherited protected method, callable here
}
}
$saucier = new SauceStation('Saucier');
echo $saucier->finishDish('Duck a l'Orange'); // "Saucier: Duck a l'Orange up!"
// $saucier->callOrderReady('anything'); // Fatal error from outside the class hierarchy
SauceStation extends KitchenStation means SauceStation inherits everything KitchenStation marked as protected or public, the way a station inherits the base techniques every station in this kitchen is expected to know, while still keeping those techniques out of reach for code entirely outside the family.
public is the menu. It's what front-of-house can order, the interface the rest of the world interacts with, regardless of what's happening behind the pass.
public function finishDish(string $dish): string
That's the only method a server, or in code terms, any other part of your application, ever needs to know about. Everything private and protected behind it is implementation detail the caller has no business depending on, because implementation detail changes, and a menu that changes its internal prep method without changing what the guest receives hasn't broken its contract with anyone.
This three-tier structure has a name in software design: encapsulation. Keep internal state and internal technique hidden behind a small, deliberate public interface, so the rest of your application depends on what a class promises to do, not on how it happens to do it today.
Constructor Promotion Is the Modern Kitchen's Faster Ticket Intake
PHP 8 added a shortcut that removes a specific kind of repetition every class with typed properties used to carry: declaring a property, then re-declaring it as a constructor parameter, then manually assigning one to the other.
// The old way, three places to touch for every property
class SauceStation
{
private string $baseStock;
private float $reductionRatio;
public function __construct(string $baseStock, float $reductionRatio)
{
$this->baseStock = $baseStock;
$this->reductionRatio = $reductionRatio;
}
}
// Constructor promotion, one place to touch
class SauceStation
{
public function __construct(
private string $baseStock,
private float $reductionRatio,
) {}
}
Both versions behave identically. The second one refuses to make you say the same thing three times. Adding the visibility modifier directly in the constructor's parameter list declares the property, types it, and assigns it, in one line per property instead of three. Once you've written a handful of classes the old way, you'll understand why this shortcut shipped, and you'll stop writing the old way entirely.
Abstract Classes Are Escoffier's Standard, Not a Specific Recipe
Escoffier's brigade system doesn't specify exactly how each station plates its dish. It specifies that every station will plate to a certain standard, consistent presentation, proper technique, timed to fire with the rest of the table. The specifics belong to each station. The standard is non-negotiable.
An abstract class is PHP's version of that standard: a contract that says every station built from this must implement certain behavior, without dictating exactly how.
abstract class KitchenStation
{
protected string $stationName;
abstract public function plate(string $dish): string;
public function announceReady(string $dish): string
{
return "{$this->stationName} ready: " . $this->plate($dish);
}
}
class SauceStation extends KitchenStation
{
public function __construct(protected string $stationName) {}
public function plate(string $dish): string
{
return "{$dish}, sauce mirrored beneath the protein";
}
}
class GardeMangerStation extends KitchenStation
{
public function __construct(protected string $stationName) {}
public function plate(string $dish): string
{
return "{$dish}, composed cold on a chilled plate";
}
}
You cannot instantiate KitchenStation directly. new KitchenStation() fails, because it's not a real station, it's the standard every real station is built against. SauceStation and GardeMangerStation each satisfy the contract in their own way, and announceReady, defined once on the abstract parent, works correctly for both, calling whichever plate implementation belongs to the actual object it's running on:
$stations = [
new SauceStation('Saucier'),
new GardeMangerStation('Garde Manger'),
];
foreach ($stations as $station) {
echo $station->announceReady('Seared Duck Breast') . "\n";
}
That's polymorphism, and it's worth naming plainly instead of dressing it up: the calling code doesn't know or care which concrete station it's talking to. It calls announceReady, and the correct plate logic runs, because each object knows how to plate its own dish. Front-of-house doesn't need to know whether table 12's protein came off the grill or out of a sous vide bath. They call it plated, and it fires.
Traits Are the Technique Every Station Needs, Taught Once
Not every shared capability belongs in a parent-child hierarchy. Some techniques cut across stations that have nothing else in common. Both the fish station and the meat station need to know proper resting technique after searing, but they're not related to each other in any inheritance sense, and forcing them into a shared parent class to get one shared method is the kind of structural overreach that makes a codebase harder to read, not easier.
PHP's traits solve exactly this:
trait RestingTechnique
{
public function rest(string $protein, int $minutes): string
{
return "{$protein} resting for {$minutes} minutes before service";
}
}
class GrillStation extends KitchenStation
{
use RestingTechnique;
public function __construct(protected string $stationName) {}
public function plate(string $dish): string
{
return "{$dish}, grill marks facing up";
}
}
class SauteStation extends KitchenStation
{
use RestingTechnique;
public function __construct(protected string $stationName) {}
public function plate(string $dish): string
{
return "{$dish}, pan sauce spooned over";
}
}
Both stations get rest() without sharing a parent class, because resting technique isn't a matter of what kind of station you are. It's a skill you use, the same way a real cook picks up a technique from another station without transferring departments to learn it.
Dependency Injection: The Walk-In Doesn't Belong to Any One Station
Every station needs the walk-in fridge at some point during service. No single station owns it, and no station should be responsible for building their own private refrigeration unit to avoid depending on the shared one. They're handed access to it, and they use it, and the kitchen doesn't grind to a halt if the walk-in's internal layout changes overnight, because no station hardcoded assumptions about how it's organized inside.
That's dependency injection, and the difference between doing it and skipping it shows up the first time you try to test or swap out a piece of your application:
class OrderService
{
private PDO $db;
public function __construct()
{
$this->db = new PDO('mysql:host=localhost;dbname=restaurant', 'user', 'pass');
}
public function submit(string $dish): bool
{
$stmt = $this->db->prepare('INSERT INTO orders (dish) VALUES (?)');
return $stmt->execute([$dish]);
}
}
// Dependency injected: the connection is handed in, not built internally
class OrderService
{
public function __construct(private PDO $db) {}
public function submit(string $dish): bool
{
$stmt = $this->db->prepare('INSERT INTO orders (dish) VALUES (?)');
return $stmt->execute([$dish]);
}
}
$connection = new PDO('mysql:host=localhost;dbname=restaurant', 'user', 'pass');
$orderService = new OrderService($connection);
The first version has hardcoded credentials baked directly into a class that has nothing to do with database configuration. Testing it means either hitting a real database or reaching for increasingly awkward mocking tricks to intercept a PDO object the class insists on building itself. The second version hands the connection in from outside. Testing OrderService now means passing in a test double instead of a real connection, and swapping databases, or pooling connections, or pointing at a read replica, happens at the call site instead of requiring surgery inside a class that was never supposed to care where its walk-in came from in the first place.
We recommend reading Rolling Your Own Custom API next if you're ready to put this structure to work. Everything in this section, the encapsulatedOrderService, the injectedPDOconnection, is the exact shape a real endpoint needs before it touches a single HTTP request, and that piece picks up precisely where this one stops.
When the Hierarchy Is Overkill
A kitchen that assigns six cooks and three sous chefs to make toast isn't organized. It's theater. The brigade system exists because fifteen people moving fast around a Saturday dinner service need it. It does not exist because hierarchy is good on principle, and PHP developers coming out of a first OOP course make this mistake constantly: building a five-layer class hierarchy for a script that needed one function and a loop.
Composition solves more problems than inheritance does, and it solves them with less structural commitment. Before reaching for extends, ask whether the relationship is a true "is-a" (a SauceStation is-a KitchenStation, and inheritance fits) or "has-a" in disguise (an OrderService has-a database connection, not is-a database connection, and injection fits better than inheritance ever would). Confusing the two produces class hierarchies that technically compile and practically resist every attempt to change them without a ripple effect three layers deep. If you're building an abstract base class for something that will only ever have one concrete implementation, you didn't build an abstraction. You built ceremony, and ceremony doesn't survive contact with a deadline any better than an overstaffed toast station survives a health inspection.
The Brigade Isn't the Point. What It Protects Is.
Escoffier didn't build the brigade de cuisine because hierarchy looks impressive on an org chart. He built it because a kitchen without clear ownership of who touches what, and clear boundaries around who answers to whom, cannot survive a real service under real pressure. Visibility modifiers, abstract contracts, and dependency injection exist in PHP for the identical reason: not to make your code look sophisticated, but to make it survive contact with a real application, a real team, and a deadline that doesn't care how elegant your class diagram is.
Go back to Part 1 if any of the fundamentals under this structure felt shaky. Read why so much of the PHP ecosystem still skips this discipline entirely if you want to understand what happens to a codebase that never had a sous chef standing over it, asking what's in the container. And when you're ready to put a properly encapsulated class in front of real HTTP traffic, building your own API from scratch is the next station on the line.
