Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Ship It

A feature-first builder’s guide to Laravel, Symfony, WordPress, TYPO3, API Platform, Nextcloud, and the rest of the modern PHP ecosystem

This book assumes you already know how to code. It does not assume you want to learn a new language to prove it. It exists for the moment someone hands you a feature, a deadline, and a blank terminal, and you need to know which command to type first.

Foreword: You Don’t Need to Love the Engine to Drive the Car

Nobody asks a delivery driver to rebuild the truck’s engine before their first shift. They get handed the keys, a route, and a deadline. If the truck breaks down, someone else gets called. Most software work is closer to that than anyone in a programming book will usually admit.

You’ve been handed a feature. Maybe it’s a client site that needs a blog and a contact form by Friday. Maybe it’s an internal tool that needs a login screen and a table nobody has to build by hand. Maybe it’s a proof of concept that needs to look real enough to get funded. Whatever it is, you did not open this book to fall in love with a programming language. You opened it because something needs to exist, and it happens to run on a language you may not even want named out loud.

That’s fine. This book will barely say its name either. What it will do is show you, feature by feature, the fastest honest route from “nothing exists yet” to “this works and I can hand it over.” Sometimes that route runs through a full framework like Laravel or Symfony. Sometimes it runs through a CMS like WordPress or TYPO3 that already solved your problem a decade ago. Sometimes the right answer is a single small library that does one thing and gets out of your way. Sometimes, when the budget allows it, the right answer is simply paying someone else to run the hard part for you.

None of that is a compromise. Picking the tool that ships fastest, holds up under a real client, and doesn’t leave you debugging someone else’s cleverness at 2 a.m. is the actual skill here. This book tries to teach that skill honestly: what each option is good at, what it quietly costs you later, and how to tell the two apart before you commit.

There’s a small aside at the end of most sections, marked “Under the hood.” It’s there for the moment, and it will come, when something these frameworks do for free stops feeling like magic and starts feeling like a question. You can ignore it completely and still ship everything in this book. Or you can read one, get curious, and end up somewhere you didn’t expect: caring, a little, about the engine after all.

Either way, let’s build the thing you were actually asked to build.

How This Book Works

This book is organized by outcome, not by technology. Every chapter after Chapter 1 is something a client or a product manager might actually ask for: “add a login screen,” “we need a blog,” “make search feel instant.” None of the chapters are named after a piece of syntax, and none of them assume you’re going to read the whole book cover to cover.

The shape of a chapter

Each feature chapter opens with a short page laying out the decision: what the feature really involves, and the two to four ecosystems that can ship it well. From there, each ecosystem gets its own page:

  • A short description of what the tool or platform actually does for you.
  • A minimal, working example: the command you’d run or the code you’d write.
  • Honest notes on when to reach for this option and when it’s the wrong fit.

Ecosystems are roughly ordered from “fastest to a working demo” to “best fit for something that needs to last,” but that ordering is a guideline, not a ranking. The right pick depends on your client’s budget, your team’s existing stack, and how long this thing needs to survive after you ship it.

The $ marker

Most of this book is free, open-source software. A few names carry a $ in their title because they’re paid products or software-as-a-service, not open source. They’re here because they’re often genuinely the fastest or most reliable path to shipping a specific feature, not because anyone paid to be in this book. Every $ option sits next to at least one open-source alternative in the same chapter, so you can weigh a subscription against your own time honestly.

“Under the hood” boxes

Most ecosystem pages end with a short aside like this one:

Under the hood: A one- or two-paragraph note on the actual language feature or engine behavior making the convenience above possible. Always optional. Always skippable.

You will ship everything in this book without ever opening one of these. They exist for the moment your curiosity gets the better of you, and for that moment, Appendix C indexes every one of them by chapter.

If you get stuck on vocabulary

Appendix B defines the recurring ecosystem terms (ORM, service container, hook, bundle, resource) by what they do for you, not by their formal computer science definition. If a chapter uses a term you don’t recognize, that’s the first place to look.

Now, let’s pick a stack.

Choosing Your Stack in Five Minutes

Before any feature gets shipped, one decision shapes everything downstream: what kind of thing you’re actually building, and what already exists that gets you most of the way there. Get this decision right and every later chapter in this book becomes a menu you pick from. Get it wrong and you’ll spend the next six months fighting a tool that was never meant to build what you’re building.

This chapter is short on purpose. It gives you a decision guide, not a religion, and a way to have something running in under a minute so you can start feeling out whether the choice was right before you’ve invested a single afternoon in it.

Framework, CMS, or Headless: What Are You Actually Building?

Most briefs describe one of three shapes, whether or not the person writing the brief knows it.

“We need an application”

Something with custom logic: a booking system, an internal dashboard, a marketplace, anything where the behavior is the product. This is framework territory. A framework like Laravel or Symfony gives you routing, a database layer, and a place to put your own logic, but no opinion about what your app actually does. You write that part.

Reach for a framework when the value of the product is the custom behavior itself, and no off-the-shelf tool already does what you’re describing.

“We need a site people can edit”

Something where the value is the content, and non-technical people need to add, edit, or reorganize it after launch: a marketing site, a blog, a documentation portal, most small business websites. This is CMS territory. A CMS like WordPress or TYPO3 gives you an editing interface, content structure, and a plugin ecosystem, on day one, without you writing an admin panel from scratch.

Reach for a CMS when the client’s real request is “and then our marketing team should be able to update this themselves,” which is most content-driven briefs whether they say it out loud or not.

“We need an API for something else to consume”

Something feeding a mobile app, a separate front end, or another team’s service, where there’s no server-rendered page at all. This is headless API territory. Tools like API Platform exist specifically to turn a data model into a documented, versioned API without hand-writing every endpoint.

Reach for a headless approach when you already know the consumer is a JavaScript front end, a mobile app, or another backend team, and a server-rendered page would just be thrown away.

When it’s genuinely unclear

Plenty of real projects are two of these at once: a marketing site that also needs a booking system, an app that also needs an editable content section. When that happens, this book generally recommends starting from whichever shape is closer to the primary value of the project, and bolting the other capability on afterward. A WordPress site with a custom plugin for the booking logic ships faster than a Laravel app that reimplements a content editor. A Laravel app with a posts table and a simple admin screen ships faster than wedging custom booking logic into WordPress hooks.

Under the hood: None of this is really about PHP itself. Frameworks, CMSes, and API-only tools are almost always built from the same underlying language features (a router matching a URL to code, an ORM turning rows into objects), just packaged around a different assumption about who will use the result and how often the content changes without a deploy.

Scaffolding a First Project in Under a Minute

Reading about a stack tells you what it claims. Running its scaffolding command for sixty seconds tells you what it actually feels like. Here’s the fastest path into each of the main options this book covers.

Laravel

composer create-project laravel/laravel my-app
cd my-app
php artisan serve

A running application at http://localhost:8000, with routing, a database connection, and an ORM already wired together.

Symfony

composer create-project symfony/skeleton my-app
cd my-app
composer require webapp
symfony server:start

The symfony/skeleton starts nearly bare; composer require webapp pulls in the Twig, routing, and form components most sites end up needing anyway.

WordPress

wp core download --path=my-site
cd my-site
wp config create --dbname=my_site --dbuser=root --dbpass=
wp core install --url=localhost:8080 --title="My Site" --admin_user=admin --admin_password=admin --admin_email=you@example.com
php -S localhost:8080

Or skip the command line entirely and use Local, which does all four steps behind a GUI in about the same amount of time.

API Platform

composer create-project api-platform/api-platform my-api
cd my-api
docker compose up -d

A running API skeleton with an interactive OpenAPI/Swagger docs page, ready for the next chapter’s example of turning a single PHP class into a full CRUD API.

What to actually compare

Don’t compare these on install time alone; they’re all under a minute. Compare what’s already decided for you the moment the install finishes: Laravel and Symfony hand you an empty application and a lot of freedom, WordPress hands you a working, editable site with nothing custom yet, and API Platform hands you a fully documented, empty API. The one that feels closest to “done” for your actual brief is usually the right one to keep building on.

Under the hood: All four of these are Composer projects: PHP’s package manager resolves a dependency tree and generates an autoloader. WordPress is the outlier: it predates Composer’s popularity and can run without it, though modern WordPress development almost always uses Composer for plugins and dependencies too.

Shipping Without a Framework: Standalone Components

Not everything needs a framework. A one-off import script doesn’t need Laravel. A cron job that pings three APIs and writes a report doesn’t need Symfony. Sometimes the fastest way to ship isn’t picking a framework or a CMS at all, it’s running composer require for the one library built for exactly this job, and writing forty lines around it.

Every package in this chapter is genuinely independent: no framework underneath, nothing to configure beyond the one thing it does. Several of them are worth knowing for another reason too: they’re the literal engines running inside the frameworks covered later in this book. Knowing them standalone makes the “under the hood” boxes in later chapters a lot less mysterious.

Command-Line Tools: Symfony/Console

The next time you run composer require or php artisan migrate, you’re using Symfony’s Console component. Both Composer and Laravel’s Artisan are built on it. None of that requires the rest of the Symfony framework; Console is a standalone package that turns a PHP class into a proper command-line tool with arguments, options, colored output, and progress bars, with nothing else attached.

composer require symfony/console
<?php
// bin/console
require __DIR__.'/../vendor/autoload.php';

use Symfony\Component\Console\Application;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;

class ImportUsersCommand extends Command
{
    protected static $defaultName = 'app:import-users';

    protected function configure(): void
    {
        $this->addArgument('file', InputArgument::REQUIRED, 'Path to the CSV file');
    }

    protected function execute(InputInterface $input, OutputInterface $output): int
    {
        $rows = array_map('str_getcsv', file($input->getArgument('file')));
        $output->writeln(sprintf('<info>Imported %d rows.</info>', count($rows)));

        return Command::SUCCESS;
    }
}

$app = new Application('My Tool', '1.0.0');
$app->add(new ImportUsersCommand());
$app->run();
php bin/console app:import-users users.csv

When to reach for this

Any time the job is a script someone will run by hand or from a cron entry: a data import, a cleanup task, a deploy step. It’s a better foundation than a raw PHP script the moment you need arguments, flags, or output that a human has to read and trust.

When it’s the wrong fit

If the “script” is really the beginning of a web application, or if you’re already inside a Laravel or Symfony project, use artisan or the app’s own bin/console instead of bootstrapping a second, separate command-line entry point.

Under the hood: Console commands are ordinary PHP classes with a configure() and an execute() method. There’s no special runtime, no compiled binary, just the same PHP interpreter you already have, pointed at a script instead of a browser request.

Talking to Other APIs: Guzzle

Almost every feature that “talks to another service” boils down to making HTTP requests and handling the response. Guzzle has been the default way to do that in PHP for over a decade, and it works identically whether you’re inside a framework or writing a forty-line script.

composer require guzzlehttp/guzzle
<?php
require 'vendor/autoload.php';

use GuzzleHttp\Client;

$client = new Client(['base_uri' => 'https://api.example.com']);

$response = $client->get('/users/42', [
    'headers' => ['Authorization' => 'Bearer ' . getenv('API_TOKEN')],
]);

$user = json_decode($response->getBody()->getContents(), true);
echo $user['name'];

Guzzle also handles the parts that get tedious to write by hand: retries, timeouts, streaming large responses, and sending concurrent requests without blocking on each one in turn.

use GuzzleHttp\Promise\Utils;

$promises = [
    'users' => $client->getAsync('/users'),
    'orders' => $client->getAsync('/orders'),
];

$results = Utils::unwrap($promises);

When to reach for this

Any script or service that needs to call a third-party API and isn’t already inside a framework that ships its own HTTP client (Laravel’s Http facade and Symfony’s HttpClient component both wrap similar ideas, and either is a fine choice if you’re already there). Guzzle is the safe default when you’re not sure yet what the rest of the project will look like.

When it’s the wrong fit

If you’re deep inside Laravel, its Http::get(...) facade is Guzzle under a friendlier syntax, so there’s rarely a reason to bypass it and reach for raw Guzzle directly.

Under the hood: Guzzle and most modern PHP HTTP clients speak PSR-7 and PSR-18, standard interfaces for HTTP messages and clients. That’s why Laravel’s Http facade, Symfony’s HttpClient, and Guzzle can all hand requests and responses to each other without any of them needing to know the others exist.

Templating Without a Framework: League/Plates

The moment a script needs to produce HTML instead of a JSON blob or a log line, mixing echo statements into PHP logic gets unreadable fast. Plates is a native-PHP templating engine: no new syntax to learn, just plain PHP files with layouts, sections, and automatic escaping, usable in any script.

composer require league/plates
<?php
require 'vendor/autoload.php';

use League\Plates\Engine;

$templates = new Engine(__DIR__ . '/templates');

echo $templates->render('profile', ['name' => 'Ada']);
<?php // templates/layout.php ?>
<!doctype html>
<html>
<head><title><?= $this->e($title ?? 'My Site') ?></title></head>
<body>
    <?= $this->section('content') ?>
</body>
</html>
<?php // templates/profile.php ?>
<?php $this->layout('layout', ['title' => 'Profile']) ?>

<?php $this->start('content') ?>
    <h1>Hello, <?= $this->e($name) ?></h1>
<?php $this->stop() ?>

$this->e() escapes output automatically, the same protection Twig or Blade gives you, just spelled out explicitly instead of hidden behind different syntax.

When to reach for this

A small internal tool, a report generator, or a script that renders a handful of HTML pages and doesn’t warrant installing a full framework’s templating stack.

When it’s the wrong fit

Once a project grows components, includes, and more than a handful of pages, a framework’s own templating engine (Blade in Laravel, Twig in Symfony) earns its keep with features like component reuse and asset compilation that Plates deliberately leaves out to stay small.

Under the hood: Because Plates templates are just PHP files, there’s no separate compile step or template cache to reason about. That simplicity is the entire point of the library, and it’s also exactly what a framework’s own templating engine trades away in exchange for more features.

Logging That Just Works: Monolog

When something goes wrong in a script that isn’t running in a browser, with no one watching, a log line is the only trace it leaves. Monolog is the logging library nearly every PHP framework wraps internally: Laravel’s Log facade and Symfony’s logger service are both Monolog with a friendlier front end. Using it directly means every project, framework or not, ends up with logs shaped the same way.

composer require monolog/monolog
<?php
require 'vendor/autoload.php';

use Monolog\Logger;
use Monolog\Handler\StreamHandler;
use Monolog\Handler\RotatingFileHandler;

$log = new Logger('import-job');
$log->pushHandler(new StreamHandler('php://stdout', Logger::INFO));
$log->pushHandler(new RotatingFileHandler(__DIR__ . '/logs/import.log', 14, Logger::DEBUG));

$log->info('Import started', ['file' => 'users.csv']);

try {
    // ... do the work ...
} catch (\Throwable $e) {
    $log->error('Import failed', ['exception' => $e]);
}

A single logger can write to several places at once: the console, a rotating file, or a service like Sentry (see Catching It in Production), just by adding another handler.

When to reach for this

Any script or service running outside a request/response cycle: a queue worker, a cron job, a CLI import. If nobody’s staring at the terminal when it fails, it needs a log.

When it’s the wrong fit

Inside Laravel or Symfony, use the framework’s own Log facade or logger service. They’re Monolog underneath, already configured with sensible handlers, and consistent with how the rest of the app logs.

Under the hood: Monolog implements PSR-3, the standard logging interface most PHP frameworks and libraries expect. That’s why a library you composer require can accept “a logger” as a constructor argument without caring whether it’s Monolog directly or a framework’s wrapper around it.

Validating Input: Respect/Validation

Every form, every API payload, every CSV upload eventually needs the same question answered: is this data actually usable? Respect/Validation gives you readable, chainable rules for answering that question, without needing a framework’s request lifecycle wrapped around it.

composer require respect/validation
<?php
require 'vendor/autoload.php';

use Respect\Validation\Validator as v;

$validator = v::key('email', v::email())
    ->key('age', v::intVal()->between(18, 120))
    ->key('username', v::alnum()->length(3, 20));

try {
    $validator->assert([
        'email' => 'ada@example.com',
        'age' => 34,
        'username' => 'ada_l',
    ]);
    echo "Valid.\n";
} catch (\Respect\Validation\Exceptions\NestedValidationException $e) {
    foreach ($e->getMessages() as $message) {
        echo "- {$message}\n";
    }
}

Rules read close to plain English and compose freely: v::stringType()->notEmpty()->length(1, 255) or v::arrayType()->each(v::stringType()) cover most of what a typical form or API payload needs without writing a single custom rule.

When to reach for this

A script or small service accepting outside input (a CSV import, a webhook receiver, a lightweight API) where pulling in a full framework just for its validation layer would be overkill.

When it’s the wrong fit

Laravel’s built-in $request->validate() and Symfony’s Validator component are both more tightly integrated with their framework’s forms and error display, and are the better default once you’re already building inside one of them.

Under the hood: Validation libraries like this one lean on PHP’s type system more than they used to. Underneath the fluent v::intVal() calls, modern versions increasingly use PHP’s own union types and enums to describe what “valid” means, rather than reinventing type-checking from scratch.

File Storage Without Buying Into a Framework: League/Flysystem

Reading and writing files sounds simple until “files” means local disk in development, S3 in production, and maybe FTP for one stubborn client. Flysystem is a filesystem abstraction: write your code against one interface, and swap where the files actually live by changing configuration, not code. It’s also, notably, the exact library powering Laravel’s Storage facade (see Laravel: Filesystem Abstraction and S3-Compatible Storage), available here with no framework attached.

composer require league/flysystem-aws-s3-v3
<?php
require 'vendor/autoload.php';

use League\Flysystem\Filesystem;
use League\Flysystem\AwsS3V3\AwsS3V3Adapter;
use Aws\S3\S3Client;

$client = new S3Client([
    'region' => 'us-east-1',
    'version' => 'latest',
]);

$adapter = new AwsS3V3Adapter($client, 'my-bucket');
$filesystem = new Filesystem($adapter);

$filesystem->write('reports/2026-09.csv', $csvContents);
$exists = $filesystem->fileExists('reports/2026-09.csv');
$filesystem->delete('reports/2025-01.csv');

Swapping the adapter to a local disk for development is a one-line change:

use League\Flysystem\Local\LocalFilesystemAdapter;

$adapter = new LocalFilesystemAdapter(__DIR__ . '/storage');
$filesystem = new Filesystem($adapter);

The rest of the code, write(), fileExists(), delete(), doesn’t change at all.

When to reach for this

A script, worker, or small service that reads or writes files and needs to work the same way locally and in production, without hard-coding “local disk” or “S3” into the business logic.

When it’s the wrong fit

Inside Laravel, use the Storage facade instead of installing Flysystem separately; it’s already there, already configured per environment, and it’s the exact same library with a friendlier API layered on top.

Under the hood: Flysystem defines a small interface (write, read, delete, fileExists, and a handful of others) and lets each adapter implement it however the underlying storage actually works. That’s ordinary interface-based polymorphism, the same idea behind PHP’s own built-in interfaces, applied to a problem developers hit constantly enough to be worth a dedicated library.

Shipping a Content Site

Marketing pages, blogs, documentation, portfolios: sites where the value is the content and someone non-technical needs to update it after launch. Writing a custom application for this is almost always the wrong call. Every option in this chapter exists specifically so you don’t have to build an editor, a content model, or a publishing workflow from scratch.

The four options here trade off differently between “fastest to a first page” and “best fit for a large, structured, or long-lived site.”

WordPress: Block Themes and the Site Editor

WordPress still powers a large share of the content web, and modern WordPress is a genuinely different tool from the one its reputation is often stuck on. Block themes and the Site Editor let a client build and rearrange full page layouts, headers, and footers visually, using the same block editor they already use for posts, with no PHP template files required for common layout changes.

wp core download --path=my-site
cd my-site
wp config create --dbname=my_site --dbuser=root --dbpass=
wp core install --url=localhost:8080 --title="My Site" --admin_user=admin --admin_password=admin --admin_email=you@example.com
wp theme install twentytwentyfive --activate

A developer’s job in a block theme is mostly theme.json: defining the color palette, spacing scale, and typography the client is allowed to choose from, so the site stays on-brand no matter what the client rearranges.

{
  "version": 2,
  "settings": {
    "color": {
      "palette": [
        { "slug": "brand-primary", "color": "#1d4ed8", "name": "Brand Primary" },
        { "slug": "brand-ink", "color": "#111827", "name": "Ink" }
      ]
    },
    "typography": {
      "fontSizes": [
        { "slug": "small", "size": "0.875rem", "name": "Small" },
        { "slug": "large", "size": "1.5rem", "name": "Large" }
      ]
    }
  }
}

Custom blocks, when a client needs something the default set doesn’t cover, are registered in PHP and can be as small as a single function.

When to reach for this

Marketing sites, blogs, and small business sites where the client explicitly wants to edit the site themselves after launch, and where the enormous plugin ecosystem (forms, SEO, caching) solves problems faster than building them.

When it’s the wrong fit

A site that’s mostly custom application logic wearing a thin content layer. Forcing that into WordPress’s content model usually costs more time than it saves.

Under the hood: Block themes store their layout as structured HTML with block comments (<!-- wp:heading -->), not PHP template tags. WordPress’s own PHP renders those blocks at request time, but the authoring format is deliberately close to plain markup, which is what lets the visual editor read it back reliably.

TYPO3: Structured Content at Enterprise Scale

Some content sites aren’t a blog with a few pages: they’re a few thousand pages, across a dozen departments, in six languages, with an editorial workflow that needs approvals before anything goes live. TYPO3 is a CMS built for exactly that scale, with a content tree, granular permissions, and multilingual support (see Shipping Multi-Language, Multi-Site) designed in from the start rather than bolted on.

composer create-project typo3/cms-base-distribution my-site
cd my-site
./vendor/bin/typo3 setup

Content lives in a page tree, and each page is built from “content elements,” structured blocks (text, images, a form, an embedded plugin) that editors arrange without touching code. Developers extend that vocabulary with custom content elements when the built-in set doesn’t cover a client’s need:

<?php
// Configuration/TCA/Overrides/tt_content.php
use TYPO3\CMS\Core\Utility\ExtensionManagementUtility;

ExtensionManagementUtility::addTcaSelectItem(
    'tt_content',
    'CType',
    ['label' => 'Team Member Card', 'value' => 'teammember_card'],
);

Permissions are set per page, per user group, so a regional editor can manage their department’s section of the tree without being able to touch anyone else’s.

When to reach for this

Large institutional or enterprise sites: universities, government agencies, multinational companies, where content ownership is genuinely distributed across teams and the editorial workflow itself is a requirement, not a nice-to-have.

When it’s the wrong fit

A small business site or a single-editor blog. TYPO3’s structure is the entire value proposition at scale and unnecessary overhead below it; WordPress ships the same kind of result faster for a simpler brief.

Under the hood: TYPO3’s TCA (Table Configuration Array) is a large PHP array describing how each content type behaves: which fields it has, how they’re validated, how they render in the backend. It’s effectively metadata-driven UI generation, a pattern that shows up again in API Platform’s approach to admin panels, just applied to editorial content instead of API resources.

Statamic ($ for commercial use): A Flat-File CMS Built on Laravel

Statamic is what a content editor looks like when a Laravel developer builds it: content lives in flat Markdown and YAML files instead of a database, version control gets you free content history, and the whole thing is still a real Laravel application underneath, so anything you already know about Laravel (see Shipping User Accounts, Shipping Background Work) still applies.

composer create-project statamic/statamic my-site
cd my-site
php please make:user
php artisan serve

Content types (“collections” and “blueprints” in Statamic’s vocabulary) are defined in YAML, and editors get a clean admin interface generated straight from that definition:

# resources/blueprints/collections/posts/post.yaml
title: Post
sections:
  main:
    fields:
      - handle: title
        field: { type: text, required: true }
      - handle: content
        field: { type: markdown }
      - handle: featured_image
        field: { type: assets, container: images }

Templates use Statamic’s own Antlers syntax or standard Blade, developer’s choice.

Licensing

Statamic is free for personal, non-commercial projects. Any commercial site needs a paid license, priced per site, which is what earns it the $ in this book: it’s open enough to evaluate for free, but shipping it for a client is a real line item.

When to reach for this

A Laravel-comfortable team building a content-heavy site, one that wants Git-based content history and doesn’t want to run a separate database just for pages and posts.

When it’s the wrong fit

A client who needs the WordPress plugin ecosystem specifically, or a project where the license cost isn’t justified by the smaller scope.

Under the hood: Storing content as flat files instead of database rows means Statamic leans heavily on PHP’s filesystem functions and caching layer for performance instead of SQL queries. For most content sites, reading a cached, pre-parsed file is at least as fast as a database round trip, and it comes with git diff as a free audit log.

Craft CMS ($): A Licensed CMS Built for Editorial Control

Craft CMS starts from a different assumption than WordPress: there’s no default content model at all. Every project defines its own custom fields, entry types, and structure from scratch, using Craft’s editor as the visual layer. That makes the first hour slower than WordPress, and the following year faster for clients whose content genuinely doesn’t fit a generic “post” model.

composer create-project craftcms/craft my-site
cd my-site
php craft setup
php craft serve

Content structure is defined through Craft’s own admin UI (fields, sections, entry types), then queried in templates with Twig:

{% for entry in craft.entries()
    .section('caseStudies')
    .client(currentClient)
    .all() %}
    <article>
        <h2>{{ entry.title }}</h2>
        {{ entry.summary }}
    </article>
{% endfor %}

Licensing

Craft’s core CMS is source-available (you can read and modify the code), but running it commercially requires a paid license per project, which is what earns it the $. There’s a free tier for evaluating it on a single-user, non-commercial site.

When to reach for this

Agencies building custom, editorial-heavy sites for clients who have specific, non-generic content needs and a budget for licensing, and who value a content model that fits them exactly rather than one that’s stretched to fit.

When it’s the wrong fit

A tight budget, a generic blog-shaped brief, or a client who wants access to WordPress’s enormous plugin marketplace. Craft’s plugin ecosystem is real but far smaller.

Under the hood: Craft’s flexible field system is built on top of a fairly conventional relational database schema, using PHP’s dynamic property access and its own query builder to make wildly different content structures feel like first-class, typed data in Twig templates rather than loosely typed arrays.

Shipping User Accounts

Sign-up, login, “forgot password,” and knowing who’s currently looking at the screen: nearly every application needs this, and almost none of them need to write it from scratch. Password hashing, session handling, and the dozen small security details around them (timing attacks, session fixation, rate limiting) are exactly the kind of code you want to inherit from a well-maintained package, not reinvent under a deadline.

Laravel: Breeze, Fortify, and Jetstream

Laravel ships three official answers to “add login,” layered by how much you want handed to you.

Breeze installs actual view files and controllers into your project. You get working registration, login, password reset, and email verification, as code you own and can edit immediately.

composer require laravel/breeze --dev
php artisan breeze:install blade
npm install && npm run dev
php artisan migrate

Fortify implements the same authentication logic as a backend-only package, with no views included, meant for pairing with a custom frontend or an API.

composer require laravel/fortify
php artisan vendor:publish --provider="Laravel\Fortify\FortifyServiceProvider"
php artisan migrate

Jetstream builds on Fortify and adds a full application shell: team management, API tokens, two-factor authentication, and a choice of Livewire or Inertia-based frontend, aimed at SaaS products that need accounts and teams from day one.

composer require laravel/jetstream
php artisan jetstream:install livewire
npm install && npm run build
php artisan migrate

When to reach for each

Breeze for a straightforward app where you want to see and customize the auth code immediately. Fortify when the frontend is Inertia, a mobile app, or something custom that doesn’t want Breeze’s bundled views. Jetstream when the product itself is multi-tenant or team-based and you’d otherwise build that structure yourself anyway.

When it’s the wrong fit

A single-page marketing site with no real user accounts, or a project where WordPress’s built-in roles (see WordPress: Roles, Capabilities, and Application Passwords) already cover the need without adding a framework at all.

Under the hood: All three packages lean on Laravel’s Hash facade, which defaults to bcrypt or argon2id, both intentionally slow algorithms designed to make brute-forcing stolen password hashes impractical. You never call a hashing function directly; the framework’s authentication guard does it for you on every login attempt.

Symfony: The Security Bundle

Symfony’s approach to authentication is configuration-first: you describe, in YAML, how users are loaded, how passwords are checked, and which routes require which role, and the framework enforces it on every request without you writing the enforcement logic by hand.

composer require symfony/security-bundle
# config/packages/security.yaml
security:
  password_hashers:
    App\Entity\User: 'auto'

  providers:
    app_user_provider:
      entity:
        class: App\Entity\User
        property: email

  firewalls:
    main:
      lazy: true
      provider: app_user_provider
      form_login:
        login_path: app_login
        check_path: app_login
      logout:
        path: app_logout

  access_control:
    - { path: ^/admin, roles: ROLE_ADMIN }
    - { path: ^/account, roles: ROLE_USER }

The Maker Bundle generates the matching entity, form, and controller in one command:

composer require symfony/maker-bundle --dev
php bin/console make:user
php bin/console make:auth
php bin/console make:migration
php bin/console doctrine:migrations:migrate

Inside a controller, checking permission is a one-line call, not a manual session check:

#[IsGranted('ROLE_ADMIN')]
public function dashboard(): Response
{
    // only reachable by ROLE_ADMIN users; Symfony enforces it before this runs
}

When to reach for this

Any Symfony application, essentially without exception. The Security Bundle is the standard, well-audited path, and hand-rolling session-based authentication next to it would only reintroduce bugs it already solved.

When it’s the wrong fit

An API-only Symfony app authenticating via tokens instead of sessions still uses the Security Bundle, just configured with a different “guard” (an API token authenticator instead of form_login), so this is less a “wrong fit” than a different configuration of the same component.

Under the hood: The #[IsGranted] attribute is a PHP 8 attribute, metadata attached directly to the method, read by Symfony at runtime via reflection. It’s the same underlying mechanism (attributes plus reflection) that lets a single class definition drive routing, validation, and serialization elsewhere in the framework without repetitive configuration files.

WordPress: Roles, Capabilities, and Application Passwords

WordPress has shipped a complete user and permissions system since long before “auth as a service” was a category. If the site is built on WordPress at all, this is very likely already solved, not a feature to add.

Out of the box, WordPress ships five roles: Subscriber, Contributor, Author, Editor, and Administrator, each with a matching set of capabilities (edit_posts, publish_posts, manage_options, and dozens more). Registration, login, and password reset screens already exist at /wp-login.php.

Custom roles and capabilities, when the defaults don’t match a client’s team structure, take a few lines:

add_role('reviewer', 'Reviewer', [
    'read' => true,
    'edit_posts' => true,
    'publish_posts' => false,
]);

if (current_user_can('publish_posts')) {
    // show the publish button
}

For headless or API-driven use (a mobile app, a decoupled frontend calling the WordPress REST API), Application Passwords let a user generate a scoped credential without exposing their real login password:

curl -u "admin:xxxx xxxx xxxx xxxx xxxx xxxx" \
  https://example.com/wp-json/wp/v2/posts

When to reach for this

Any WordPress project. Reimplementing authentication alongside WordPress core rather than extending its role system is almost always wasted effort and a security liability.

When it’s the wrong fit

A headless setup wanting modern token-based auth (JWT, OAuth) instead of Application Passwords benefits from a dedicated plugin (such as the JWT Authentication plugin), since that flow isn’t part of WordPress core by default.

Under the hood: WordPress capabilities are just strings checked with current_user_can(), stored as serialized PHP data against each role in the database. There’s no formal permissions engine, which is exactly why it’s so easy to add a custom capability: it’s a string in an array, not a schema migration.

Shipping an Admin Back Office

Almost every application eventually needs an internal screen where someone, often not a developer, manages the underlying data: editing products, approving orders, updating a settings table. Building that screen by hand, form by form, is one of the least valuable ways to spend a sprint when so much of it can be generated from the data model you already have.

Laravel: Filament in an Afternoon

Filament reads an Eloquent model and generates a full admin resource around it: list view with search and filters, a create form, an edit form, and delete actions, from a single PHP class you mostly just configure rather than write from scratch.

composer require filament/filament
php artisan filament:install --panels
php artisan make:filament-resource Product --generate

That last command inspects the products table and pre-fills a resource class. What’s left is usually a short list of field definitions:

use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Select;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Columns\BooleanColumn;

public static function form(Form $form): Form
{
    return $form->schema([
        TextInput::make('name')->required(),
        TextInput::make('price')->numeric()->prefix('$'),
        Select::make('category_id')->relationship('category', 'name'),
    ]);
}

public static function table(Table $table): Table
{
    return $table->columns([
        TextColumn::make('name')->searchable(),
        TextColumn::make('price')->money('usd'),
        BooleanColumn::make('is_active'),
    ]);
}

That’s a searchable, sortable, paginated admin screen with validated create and edit forms, no hand-written HTML or controller logic.

When to reach for this

Any Laravel project that needs an internal admin screen and doesn’t need to customize its behavior beyond what a schema-driven form and table can express, which covers the large majority of real “back office” requests.

When it’s the wrong fit

A public-facing customer dashboard with heavily custom UX, where “generated admin panel” would fight the design rather than speed it up. Filament is built for internal tools, not customer-facing product surfaces.

Under the hood: Filament’s form and table builders are fluent, chainable PHP objects (TextInput::make(...)->required()->numeric()), a pattern made pleasant to write by PHP’s named arguments and first-class enum support, which let a single method call configure behavior that used to need several array keys or config options to express.

Symfony: EasyAdmin and Sonata

Symfony has two well-established answers here, sized for different jobs.

EasyAdmin generates a clean CRUD interface from a Doctrine entity with minimal configuration, aimed squarely at “I need an admin screen this week.”

composer require easycorp/easyadmin-bundle
php bin/console make:admin:dashboard
php bin/console make:admin:crud
use EasyCorp\Bundle\EasyAdminBundle\Config\Crud;
use EasyCorp\Bundle\EasyAdminBundle\Field\TextField;
use EasyCorp\Bundle\EasyAdminBundle\Field\MoneyField;

class ProductCrudController extends AbstractCrudController
{
    public static function getEntityFqcn(): string
    {
        return Product::class;
    }

    public function configureFields(string $pageName): iterable
    {
        yield TextField::new('name');
        yield MoneyField::new('price')->setCurrency('USD');
        yield AssociationField::new('category');
    }
}

Sonata Admin is the heavier, older, and more extensible option: more configuration up front, but built to handle complex permission rules, nested admin relationships, and highly customized workflows that EasyAdmin isn’t designed to stretch to.

When to reach for each

EasyAdmin for most internal tools: fast to set up, easy to read, sufficient for the majority of CRUD-shaped admin needs. Sonata when the admin panel itself is a serious piece of the product, with complex permissions or deeply nested relationships that need more structure than EasyAdmin’s simpler model provides.

When it’s the wrong fit

A public customer-facing dashboard, same caveat as Filament: both tools are built for internal, trusted-user administration, not consumer product surfaces.

Under the hood: Both bundles read Doctrine’s entity metadata (the same annotations or attributes that define your database schema) to infer field types automatically. A price column typed as decimal in your entity becomes a numeric field in the admin with no extra declaration, because the framework already knows the type.

API Platform: An Admin Generated From Your API

If you’re already building an API with API Platform (see Shipping an API Other Teams Can Use), you’ve already described your data well enough to generate an admin panel from it too, with no separate admin-specific code.

Given a resource already exposed through the API:

#[ApiResource]
class Product
{
    public ?int $id = null;

    #[ApiProperty(description: 'The product name')]
    public string $name;

    public float $price;

    #[ApiProperty]
    public ?Category $category = null;
}

API Platform’s admin package reads the API’s own OpenAPI schema and renders a working React-based admin interface (list, create, edit, delete) against it directly:

composer create-project api-platform/admin my-admin
// src/App.js
import { HydraAdmin } from '@api-platform/admin';

export default () => <HydraAdmin entrypoint="https://api.example.com" />;

No field-by-field configuration is required to get a working panel; you only add configuration where the defaults need overriding, like a custom widget for a specific field.

When to reach for this

Any project already built around an API Platform backend. The admin panel is nearly free at that point: it’s reading metadata you already produced for API documentation, not asking you to describe your data model a second time.

When it’s the wrong fit

A project with no existing API layer. Standing up an API purely to get this admin generator, when Filament or EasyAdmin would generate the same panel directly from the database, is unnecessary indirection.

Under the hood: This works because API Platform generates a machine-readable OpenAPI (and Hydra/JSON-LD) description of your API automatically, from PHP attributes on your classes. The admin panel is a generic client that can render a UI from any API described that way, not something written specifically for your project.

WordPress: Custom Post Types and ACF as a CRUD Engine

WordPress’s admin screens weren’t originally designed as a general-purpose CRUD generator, but Custom Post Types plus Advanced Custom Fields (ACF) end up functioning as one, and it’s a legitimate, fast way to give a client a management screen for something that isn’t a blog post at all: team members, case studies, product listings.

add_action('init', function () {
    register_post_type('team_member', [
        'label' => 'Team Members',
        'public' => true,
        'show_in_rest' => true,
        'menu_icon' => 'dashicons-groups',
        'supports' => ['title', 'thumbnail'],
    ]);
});

That alone adds a full list screen, with search, bulk actions, and pagination, to the WordPress admin sidebar. ACF then adds structured custom fields to it through a visual field builder, no code required for common field types:

acf_add_local_field_group([
    'key' => 'group_team_member',
    'title' => 'Team Member Details',
    'fields' => [
        ['key' => 'field_role', 'label' => 'Role', 'name' => 'role', 'type' => 'text'],
        ['key' => 'field_linkedin', 'label' => 'LinkedIn', 'name' => 'linkedin', 'type' => 'url'],
    ],
    'location' => [[['param' => 'post_type', 'operator' => '==', 'value' => 'team_member']]],
]);

The result is a full create/edit/list/delete interface for “team members,” built entirely from configuration, with show_in_rest also exposing it through the WordPress REST API for free.

When to reach for this

Any WordPress site that needs to manage a second kind of structured content beyond posts and pages, and where the client is already comfortable in the WordPress admin.

When it’s the wrong fit

Complex relational data with many-to-many relationships and heavy business logic. WordPress’s post-based data model bends a long way, but it’s still fundamentally a content model, not a general relational database admin tool.

Under the hood: Custom Post Types don’t create new database tables. Every post type, post, page, or your own team_member, is stored as a row in the same wp_posts table, distinguished by a post_type column. That’s why registering a new content type is a single function call instead of a migration.

Laravel Nova ($): The Official, Supported Alternative to Filament

Nova is Laravel’s own first-party admin panel, built and maintained by the Laravel team itself rather than the open-source community. Functionally it overlaps heavily with Filament: resources generated from Eloquent models, searchable and filterable tables, custom fields and actions. The difference is who’s on the other end when something breaks or a new Laravel version ships.

composer require laravel/nova
php artisan nova:install
php artisan make:nova-resource Product
public function fields(NovaRequest $request): array
{
    return [
        ID::make()->sortable(),
        Text::make('Name')->sortable()->rules('required', 'max:255'),
        Currency::make('Price'),
        BelongsTo::make('Category'),
    ];
}

Licensing

Nova is sold under a paid, per-developer or per-site license, with no free tier for commercial use, which is what earns it the $. In exchange, you get official support channels, guaranteed compatibility with new Laravel releases on the same schedule, and a roadmap set by the same team that builds the framework.

When to reach for this

Teams or agencies that want an admin panel with an official support contract behind it, or that are already paying for other Laravel ecosystem products (Forge, Vapor, see Shipping to Production) and want one vendor accountable for the whole stack.

When it’s the wrong fit

A budget-conscious project, or a team happy to rely on Filament’s large open-source community for support instead of a paid vendor relationship. Feature-for-feature, most projects can’t tell the difference from the outside.

Under the hood: Being a paid product changes nothing about how Nova integrates with Laravel: it’s installed via Composer like anything else, using the same service provider and package discovery mechanism as free packages. The license model is a business decision layered on top of ordinary PHP package architecture, not a technical one.

Shipping an API Other Teams Can Use

At some point, someone else needs to talk to your data: a mobile app, a partner’s system, a frontend built by a different team. That means REST or GraphQL endpoints, request validation, consistent error responses, and ideally documentation that doesn’t go stale the moment the code changes. Hand-writing all of that per resource is exactly the kind of repetitive work modern PHP tooling exists to eliminate.

API Platform: A Full API From One PHP Class

API Platform’s entire pitch is in this example. One class, decorated with a single attribute, becomes a complete, documented API:

<?php

namespace App\Entity;

use ApiPlatform\Metadata\ApiResource;
use Doctrine\ORM\Mapping as ORM;

#[ORM\Entity]
#[ApiResource]
class Product
{
    #[ORM\Id, ORM\GeneratedValue, ORM\Column]
    public ?int $id = null;

    #[ORM\Column(length: 255)]
    public string $name;

    #[ORM\Column]
    public float $price;
}

That’s it. Running this gives you, with no further code:

  • GET /api/products and GET /api/products/{id}
  • POST /api/products, PUT, and DELETE, with request validation
  • An interactive OpenAPI/Swagger documentation page at /api/docs
  • A GraphQL endpoint at /api/graphql, if the GraphQL package is installed

Restricting which operations are exposed, or filtering, is configuration on the same attribute rather than new controller code:

#[ApiResource(
    operations: [new GetCollection(), new Get(), new Post(security: 'is_granted("ROLE_ADMIN")')],
)]
#[ApiFilter(SearchFilter::class, properties: ['name' => 'partial'])]
class Product
{
    // ...
}

When to reach for this

Any project where the API is the product, or where you need to move fast on a data model that’s still evolving and don’t want to hand-maintain matching controllers, serializers, and docs for every change.

When it’s the wrong fit

An API with only one or two endpoints and no plan to grow, where the convention and setup cost of API Platform outweighs just writing two controller methods directly.

Under the hood: This is possible because PHP attributes are readable at runtime via reflection, letting API Platform inspect your class, its properties, and their types, then generate routing, validation, and an OpenAPI schema from that single source of truth instead of three separate ones.

Laravel: Sanctum, Resources, and API Versioning

Laravel’s default approach is more manual than API Platform’s, and that’s often the point: routes, controllers, and response shapes stay explicit and easy to reason about, at the cost of writing a bit more per endpoint.

Sanctum handles authentication for both SPA/mobile clients (session-based) and third-party API consumers (token-based), from one package.

composer require laravel/sanctum
php artisan vendor:publish --provider="Laravel\Sanctum\SanctumServiceProvider"
php artisan migrate
// issuing a token
$token = $user->createToken('mobile-app')->plainTextToken;

// protecting a route
Route::middleware('auth:sanctum')->get('/api/user', fn (Request $r) => $r->user());

API Resources shape a model into a consistent JSON response, decoupling your database columns from your API’s public contract:

class ProductResource extends JsonResource
{
    public function toArray(Request $request): array
    {
        return [
            'id' => $this->id,
            'name' => $this->name,
            'price_usd' => number_format($this->price, 2),
        ];
    }
}

// in a controller
return ProductResource::collection(Product::paginate());

Versioning is typically just route prefixing, kept deliberately simple:

Route::prefix('v1')->group(base_path('routes/api_v1.php'));
Route::prefix('v2')->group(base_path('routes/api_v2.php'));

When to reach for this

The default choice for a Laravel project exposing an API alongside a web app, or a small-to-medium standalone API where explicit control over each response shape matters more than automatic generation.

When it’s the wrong fit

A large, rapidly evolving data model where writing a Resource class by hand for every model becomes real maintenance overhead. That’s the case API Platform is built for.

Under the hood: Sanctum’s API tokens are stored hashed, like passwords, but with PHP’s hash() function rather than password_hash(): tokens are high-entropy random strings rather than user-chosen passwords, so a fast hash is an appropriate and sufficient defense here.

WordPress: The Built-In REST API

Every modern WordPress install already exposes a REST API, with no plugin and no configuration. Posts, pages, media, users, and any custom post type marked show_in_rest are queryable immediately:

curl https://example.com/wp-json/wp/v2/posts
curl https://example.com/wp-json/wp/v2/posts/42
curl "https://example.com/wp-json/wp/v2/posts?search=laravel&per_page=5"

This is also what powers the block editor itself, so it’s a well-exercised, production-grade API, not an afterthought bolted on for external consumers.

Custom post types opt in with one flag (see Custom Post Types and ACF as a CRUD Engine):

register_post_type('case_study', [
    'public' => true,
    'show_in_rest' => true,
]);

Custom endpoints, for anything the default post/page shape doesn’t cover, register in a few lines:

add_action('rest_api_init', function () {
    register_rest_route('myapp/v1', '/stats', [
        'methods' => 'GET',
        'callback' => function () {
            return ['total_posts' => wp_count_posts()->publish];
        },
        'permission_callback' => '__return_true',
    ]);
});

Writes (POST, PUT, DELETE) require authentication, typically Application Passwords for server-to-server use, or a plugin adding OAuth/JWT for third-party client apps.

When to reach for this

A headless setup where WordPress is purely the content backend for a separate frontend (a JavaScript app, a mobile app), or any integration that just needs to read existing WordPress content programmatically.

When it’s the wrong fit

A data model that doesn’t resemble posts, pages, or a custom post type at all. At that point you’re fighting WordPress’s content-shaped API rather than benefiting from it, and a dedicated API tool (API Platform, Laravel) fits better.

Under the hood: The REST API’s routing and response shaping are built on WordPress’s long-standing hooks and filters system, the same add_action/add_filter pattern that powers themes and plugins. There’s no separate API framework underneath; it’s the same extension mechanism WordPress has used since 2004, pointed at JSON responses instead of HTML.

Shipping Real-Time Features

Live notifications, a presence indicator, a chat widget, a dashboard number that updates itself: any feature where “the page just knows” something changed without a manual refresh. PHP’s traditional request/response model doesn’t naturally hold an open connection to push updates, but the modern ecosystem has closed that gap from several directions, some requiring you to run your own WebSocket server, some handing that job to someone else entirely.

Laravel: Reverb and Livewire Without Writing JavaScript

Reverb is Laravel’s own WebSocket server, self-hosted and free, built to slot directly into Laravel’s existing broadcasting system with no third-party service required.

composer require laravel/reverb
php artisan reverb:install
php artisan reverb:start
// broadcasting an event from anywhere in the app
broadcast(new OrderShipped($order))->toOthers();

// the event class
class OrderShipped implements ShouldBroadcast
{
    public function __construct(public Order $order) {}

    public function broadcastOn(): Channel
    {
        return new PrivateChannel('orders.' . $this->order->user_id);
    }
}

Paired with Livewire, the frontend side often needs no JavaScript at all. A Livewire component can listen for a broadcast event and re-render automatically:

class OrderStatus extends Component
{
    public Order $order;

    #[On('echo-private:orders.{order.user_id},OrderShipped')]
    public function refreshStatus(): void
    {
        $this->order->refresh();
    }

    public function render()
    {
        return view('livewire.order-status');
    }
}

The Blade template just renders $order->status; Livewire handles re-rendering that fragment over the WebSocket connection when the event fires, no client-side state management written by hand.

When to reach for this

Laravel projects wanting real-time features (notifications, live status updates, simple chat) without adopting a separate frontend framework or paying for a third-party WebSocket service.

When it’s the wrong fit

A highly interactive, JavaScript-heavy frontend (a canvas-based editor, a complex single-page app) where Livewire’s server-round-trip model adds latency a client-side framework wouldn’t have. Reverb still works there as the transport; Livewire specifically is the piece worth reconsidering.

Under the hood: Reverb is built on PHP Fibers, introduced in PHP 8.1, which let a single PHP process hold many open connections and switch between them cooperatively instead of blocking. That’s what makes a persistent WebSocket server practical to write in PHP at all, a job PHP’s traditional one-request-per-process model was never suited for.

Symfony: UX Turbo and Mercure

Symfony’s real-time story is built on Mercure, an open protocol (not a Symfony-specific invention) for pushing updates to browsers over standard HTTP, using Server-Sent Events rather than a custom WebSocket implementation. That means any client, not just a Symfony frontend, can subscribe to it.

composer require symfony/mercure-bundle
# Mercure hub runs as a small standalone binary, or via Docker
use Symfony\Component\Mercure\HubInterface;
use Symfony\Component\Mercure\Update;

class OrderController
{
    public function ship(Order $order, HubInterface $hub): Response
    {
        $order->markShipped();

        $hub->publish(new Update(
            "orders/{$order->getId()}",
            json_encode(['status' => 'shipped']),
        ));

        return new Response('OK');
    }
}

Turbo, via the symfony/ux-turbo package, subscribes the frontend to that same update and swaps the relevant HTML fragment automatically, with a data attribute instead of hand-written JavaScript:

<turbo-stream-source src="{{ mercure('orders/' ~ order.id) }}"></turbo-stream-source>

<div id="order-{{ order.id }}-status">
    {{ order.status }}
</div>

When the server broadcasts a matching Turbo Stream update, Turbo replaces that <div> in place, no page reload, no custom client-side code.

When to reach for this

Symfony projects that want real-time updates without introducing a separate JavaScript framework, especially where using an open, HTTP-based protocol (rather than a proprietary WebSocket server) matters for infrastructure or compliance reasons.

When it’s the wrong fit

Very high-frequency, bidirectional real-time needs (live multiplayer interaction, a trading dashboard with sub-second updates), where Server-Sent Events’ one-way-by-design model is a worse fit than a true WebSocket connection.

Under the hood: Server-Sent Events are a plain HTTP feature: a long-lived response that keeps sending chunks over time. Mercure builds a full publish/subscribe hub on top of that single primitive, which is part of why it works through ordinary HTTP infrastructure (proxies, load balancers) that a raw WebSocket connection sometimes needs special configuration to pass through.

Nextcloud Talk: Real-Time Built Into a Larger Platform

Nextcloud Talk is worth studying even if you never touch Nextcloud’s codebase, because it’s a real-time chat and video calling system, at genuine production scale, built as a PHP application’s app rather than a from-scratch product. It’s proof that “PHP can’t do real-time” was never really true, just underused.

Talk ships as an installable app inside any Nextcloud instance:

php occ app:install spreed
php occ app:enable spreed

For basic chat, Talk relies on the same kind of polling and Server-Sent Event techniques covered elsewhere in this chapter. For audio and video calls at scale, it hands off to a dedicated High-Performance Backend (a separate signaling server), rather than trying to do everything inside PHP’s request lifecycle:

php occ talk:signaling:add wss://signaling.example.com "shared-secret-here"

That split (PHP handling accounts, permissions, and chat history; a dedicated service handling the actual media stream) is a pattern worth borrowing directly: let PHP do what it’s good at, and hand off the genuinely specialized real-time media work to a tool built for exactly that.

When to reach for this

Not something you’ll composer require into your own project, but a useful reference architecture when your own real-time feature starts wanting video or audio, not just data updates: keep PHP as the source of truth for accounts and permissions, and delegate the media transport to a dedicated service rather than forcing it through PHP.

When it’s the wrong fit

If you’re evaluating whether to self-host Nextcloud itself for your organization’s needs (see Shipping File Storage and Collaboration), Talk is simply one of the apps that come with it, not a separate decision.

Under the hood: Nextcloud’s app system lets features like Talk be installed, updated, and disabled independently of Nextcloud core, using PHP’s own package and autoloading conventions internally. It’s the same instinct behind WordPress plugins or Symfony bundles: a stable core, with functionality layered on as discrete, swappable units.

Pusher ($): Managed WebSockets Without Running Your Own Server

Pusher does one thing: it runs the WebSocket infrastructure for you, so a feature that would otherwise need Reverb or Mercure running as a service you operate becomes an API call and an npm package instead.

composer require pusher/pusher-php-server
$pusher = new Pusher\Pusher(
    getenv('PUSHER_KEY'),
    getenv('PUSHER_SECRET'),
    getenv('PUSHER_APP_ID'),
    ['cluster' => 'us2', 'useTLS' => true],
);

$pusher->trigger('orders-channel', 'order.shipped', [
    'order_id' => $order->id,
    'status' => 'shipped',
]);

Laravel’s broadcasting system, notably, was built with Pusher as its original target and still supports it as a drop-in swap for Reverb (same event classes, same broadcast() call, different config value):

// .env
BROADCAST_DRIVER=pusher

Pricing

Pusher bills by concurrent connections and messages per day, with a free tier generous enough for development and small production apps, scaling to a real monthly cost once traffic grows, which is what earns it the $.

When to reach for this

A team that doesn’t want to operate WebSocket infrastructure at all, or a project where traffic is unpredictable enough that a managed, auto-scaling connection layer is worth paying for over self-hosting Reverb or a Mercure hub.

When it’s the wrong fit

A project with predictable, modest real-time traffic where Reverb or Mercure, both free and self-hosted, cost nothing beyond the server you’re likely already running.

Under the hood: Because Laravel’s broadcasting abstraction was designed against Pusher’s API shape from the start, swapping between Pusher and Reverb is a configuration change, not a code change. The lesson generalizes: a good abstraction layer is what makes “self-host it” versus “pay someone else to run it” a decision you can defer, or reverse, without a rewrite.

Shipping File Storage and Collaboration

Uploads, sharing links, folder permissions, version history: “our own Dropbox, please” is a request that comes up more often than its scope suggests, and the honest answer is rarely “build a Dropbox.” Sometimes an entire ready-made platform is the right call, sometimes a storage abstraction bolted onto an existing app is enough.

Nextcloud: A Ready-Made Drive, Not a DIY Project

When the actual request is “we need our own Dropbox,” the pragmatic answer is almost never to build one. Nextcloud is a complete, mature, self-hosted file sync and sharing platform: desktop and mobile sync clients, sharing links with expiration and passwords, version history, and a full permissions model, all written in PHP, all ready to deploy today.

docker run -d -p 8080:80 \
  -v nextcloud:/var/www/html \
  nextcloud

Or, for a production-ready setup with the reverse proxy, database, and Redis cache already wired together, the All-in-One Docker image (see Shipping to Production) is the officially recommended path.

Once running, provisioning users and setting quotas is a command-line job, no custom code required:

php occ user:add jane.doe
php occ user:add-app-password jane.doe --group=editors
php occ files:external:create "shared-drive" local null::null -c datadir=/mnt/shared

Extending it, if a client needs something the built-in feature set doesn’t cover, means writing an app in Nextcloud’s own plugin system, the same architecture that powers Nextcloud Talk, rather than forking the whole platform.

When to reach for this

Any request that’s genuinely “we need our own file storage and sharing system,” particularly for clients with data residency or privacy requirements that rule out Google Drive or Dropbox outright.

When it’s the wrong fit

A feature that’s really just “let users upload a profile picture” or “attach a PDF to this order.” That’s ordinary file upload handling (see Laravel: Filesystem Abstraction and S3-Compatible Storage), not a reason to stand up an entire platform.

Under the hood: Nextcloud’s file synchronization relies on chunked uploads and ETags to detect what changed since the last sync, letting a client transfer only the parts of a large file that actually changed rather than re-uploading the whole thing, a meaningful engineering problem solved once, in PHP, so no app built on top of it has to solve it again.

Laravel: Filesystem Abstraction and S3-Compatible Storage

Laravel’s Storage facade wraps League/Flysystem (see that chapter for the standalone version of the same idea) behind a simple, disk-agnostic API. The same code writes to your local disk in development and to S3 in production, decided entirely by configuration.

// config/filesystems.php
'disks' => [
    's3' => [
        'driver' => 's3',
        'key' => env('AWS_ACCESS_KEY_ID'),
        'secret' => env('AWS_SECRET_ACCESS_KEY'),
        'region' => env('AWS_DEFAULT_REGION'),
        'bucket' => env('AWS_BUCKET'),
    ],
],
use Illuminate\Support\Facades\Storage;

// storing an uploaded file
$path = $request->file('avatar')->store('avatars', 's3');

// generating a temporary, signed download link
$url = Storage::disk('s3')->temporaryUrl($path, now()->addMinutes(10));

// reading it back
$contents = Storage::disk('s3')->get($path);

Switching 'driver' => 's3' to 'driver' => 'local' for local development, or to a DigitalOcean Spaces or Cloudflare R2 endpoint in production, changes nothing else in the application code.

When to reach for this

Any feature involving user uploads: avatars, attachments, generated PDFs, exported reports. This is the default, boring, correct way to handle files in a Laravel app, and boring is exactly what you want here.

When it’s the wrong fit

A request for full sharing, sync, and collaboration features (see Nextcloud) rather than simple upload-and-retrieve. Storage handles files; it doesn’t give you sharing links, version history, or a sync client.

Under the hood: temporaryUrl() generates a pre-signed URL, a link with a cryptographic signature and expiration baked into the query string, that S3 validates itself without any request touching your Laravel app. That’s why serving a private file this way doesn’t cost your server any bandwidth at all.

WordPress: The Media Library at Scale

WordPress’s Media Library handles uploads, automatic thumbnail generation, and a searchable grid view out of the box, no plugin required. For most content sites, that’s the entire feature, already done.

$attachment_id = media_handle_upload('file', $post_id);
$url = wp_get_attachment_url($attachment_id);
$thumb = wp_get_attachment_image_url($attachment_id, 'medium');

The first real scaling problem is almost always local disk space and server bandwidth once a site has thousands of images. The standard fix is offloading storage to S3-compatible object storage via a plugin, without changing how editors interact with the Media Library at all:

wp plugin install amazon-s3-and-cloudfront --activate
wp media-offload run

From that point, uploads still go through the same familiar interface, but files are transparently stored on and served from object storage instead of the server’s own disk.

The second common scaling problem is image processing itself: generating several thumbnail sizes for every upload is CPU-intensive at volume, which is usually addressed by offloading resizing to a CDN’s on-the-fly image service rather than pre-generating every size on upload.

When to reach for this

Any WordPress site with more than a handful of images, which is nearly all of them. Even a small brochure site benefits from the automatic thumbnail sizes and searchable grid this provides for free.

When it’s the wrong fit

A site with genuinely enormous media needs (video hosting, a media-heavy application at real scale) is usually better served by a dedicated media platform integrated via API, rather than stretching the Media Library far past what it was designed for.

Under the hood: WordPress generates its thumbnail sizes using PHP’s GD or Imagick extension, whichever is available on the server, at upload time. That single design decision, resizing eagerly rather than lazily, is exactly why large media libraries eventually need the offloading strategies described above: the cost is paid once per upload, but paid by every upload.

Shipping a Storefront

Products, a cart, checkout, and taking real payments without accidentally storing a credit card number somewhere you shouldn’t. E-commerce is one of the areas where “just build it yourself” is rarely the pragmatic answer: taxes, refunds, inventory, and payment compliance all carry real risk if handled casually.

WooCommerce: E-Commerce on Top of WordPress

If a WordPress site (see Shipping a Content Site) needs to sell things, WooCommerce is the default answer, and for good reason: it’s a full store (products, variations, cart, checkout, orders, coupons) added as a plugin, using the same admin your client already knows.

wp plugin install woocommerce --activate
wp wc --version

Products are stored as a custom post type, so anything already familiar about managing WordPress content carries over directly:

$product = new WC_Product_Simple();
$product->set_name('Wireless Mouse');
$product->set_regular_price('29.99');
$product->set_stock_quantity(50);
$product->save();

Payment processing plugs in through gateway extensions rather than custom integration code:

wp plugin install woocommerce-gateway-stripe --activate

Custom logic (special pricing rules, a loyalty program, an integration with an external inventory system) hooks into WooCommerce’s own action and filter system, the same extension pattern as WordPress core:

add_filter('woocommerce_product_get_price', function ($price, $product) {
    if (is_user_logged_in() && current_user_can('wholesale_customer')) {
        return $price * 0.85;
    }
    return $price;
}, 10, 2);

When to reach for this

Any storefront where the site is already WordPress, or where the client’s actual need is closer to “a content site that also sells a modest catalog of products” than a high-volume, highly custom commerce platform.

When it’s the wrong fit

High-transaction-volume commerce with complex custom checkout logic, where WordPress’s underlying architecture starts fighting the performance and customization needs. That’s Sylius territory.

Under the hood: WooCommerce products are stored using the same wp_posts table as any other content type, with product-specific data (price, stock) kept in wp_postmeta. It’s the same “everything is a post” architecture from Custom Post Types, stretched to model an entire commerce catalog.

Sylius: A Symfony-Based E-Commerce Framework

Sylius takes the opposite approach from WooCommerce: instead of a plugin bolted onto a content platform, it’s an e-commerce framework built from Symfony components from the ground up, aimed at stores with real custom business logic that a generic plugin architecture would fight rather than support.

composer create-project sylius/sylius-standard my-shop
cd my-shop
symfony console sylius:install
symfony server:start

Because it’s built on Symfony, extending it means writing ordinary Symfony code (events, services, Doctrine entities) rather than learning a commerce-specific plugin API:

class OrderPlacedListener
{
    public function __construct(private LoyaltyPointsCalculator $calculator) {}

    public function onOrderPlaced(ResourceControllerEvent $event): void
    {
        $order = $event->getSubject();
        $this->calculator->awardPoints($order->getCustomer(), $order->getTotal());
    }
}
services:
    App\EventListener\OrderPlacedListener:
        tags:
            - { name: kernel.event_listener, event: sylius.order.post_create, method: onOrderPlaced }

That’s the same event-listener pattern used throughout Symfony (see Shipping Background Work), applied to commerce events instead of custom application events.

When to reach for this

A storefront with genuinely custom pricing rules, order workflows, or B2B logic (tiered pricing, approval workflows, multi-warehouse fulfillment) that would mean fighting a generic plugin system elsewhere.

When it’s the wrong fit

A straightforward catalog of products with standard checkout needs. That’s a great deal more setup and Symfony familiarity than WooCommerce requires for the same practical result.

Under the hood: Sylius is built almost entirely from reusable Symfony components (the Form component, the Workflow component for order state machines, Doctrine for persistence), the same building blocks covered in Chapter 2, assembled specifically for commerce rather than invented from scratch.

Laravel: Cashier and Stripe ($) for Custom Checkouts

Not every “we need to take payments” request is a storefront. Often it’s a single subscription plan, a one-time checkout, or a usage-based billing model bolted onto an app that isn’t a store at all. Cashier is Laravel’s official wrapper around Stripe (and, in a separate package, Paddle), designed for exactly that.

composer require laravel/cashier
php artisan vendor:publish --tag="cashier-migrations"
php artisan migrate
class User extends Authenticatable
{
    use Billable;
}

// starting a subscription
$user->newSubscription('default', 'price_monthly_pro')
    ->create($paymentMethodId);

// checking access
if ($user->subscribed('default')) {
    // grant access
}

// a one-time checkout, no subscription
return $user->checkout(['price_one_time_item']);

Webhooks (payment succeeded, subscription canceled, card expired) are handled by a route Cashier registers automatically, keeping your database in sync with Stripe’s state without polling.

Pricing

Stripe itself is a paid, transaction-fee-based service (a percentage plus a fixed amount per successful charge), which is what earns it the $ here. Cashier, the Laravel integration package, is free and open source; the cost is entirely on the payment processing side.

When to reach for this

A Laravel app that needs subscriptions, one-time payments, or usage billing, but isn’t a multi-product catalog store. Think SaaS pricing tiers, not a shopping cart.

When it’s the wrong fit

An actual product catalog with inventory, variations, and shipping. That’s WooCommerce or Sylius territory; Cashier has no concept of a “product” beyond a Stripe price ID.

Under the hood: Cashier verifies incoming Stripe webhooks using a cryptographic signature check against a shared secret, confirming the request genuinely came from Stripe and wasn’t spoofed, before ever trusting its payload to update a subscription’s status in your database.

Shipping Search That Feels Instant

A search box backed by WHERE title LIKE '%query%' works until it doesn’t: no typo tolerance, no relevance ranking, and a full table scan waiting to happen as the data grows. Real search, the kind that feels instant and forgiving, is a solved problem with dedicated tools built for it. The trick is wiring your data into one without building a search engine yourself.

Laravel: Scout With Meilisearch or Algolia ($)

Scout adds full-text search to Eloquent models by keeping a search index in sync automatically, every time a model is saved, updated, or deleted, without writing that synchronization logic yourself.

composer require laravel/scout meilisearch/meilisearch-php
class Product extends Model
{
    use Searchable;

    public function toSearchableArray(): array
    {
        return ['name' => $this->name, 'description' => $this->description];
    }
}

// searching
$results = Product::search('wireles mouse')->get();

That typo in “wireles” still returns the mouse; typo tolerance and relevance ranking are exactly what the underlying search engine handles and a SQL LIKE query never could.

Meilisearch is open source and self-hosted, a single binary with sensible defaults out of the box:

docker run -p 7700:7700 getmeili/meilisearch

Algolia is a managed, paid alternative, same Scout driver, no infrastructure to run:

composer require algolia/scout-extended
// config/scout.php
'driver' => env('SCOUT_DRIVER', 'algolia'),

Pricing

Meilisearch is free to self-host. Algolia bills by search volume and records indexed, with a free tier for small projects, which is what earns it the $: the same Scout code works against either, so the choice is genuinely just “run it yourself or pay someone else to.”

When to reach for this

Any Laravel app with a search box over more than a trivial amount of data: a product catalog, a documentation site, a directory of listings.

When it’s the wrong fit

Search needs simple enough that an indexed database column and a basic LIKE query genuinely suffice, where adding a search engine is unnecessary infrastructure for the problem at hand.

Under the hood: Scout’s driver system is a plain PHP interface; Meilisearch and Algolia drivers both implement the same handful of methods (update, delete, search). Swapping between them is a config change specifically because Scout was designed against that interface rather than either engine’s specific API.

TYPO3: Solr and Elasticsearch Integration

Large TYPO3 sites (see Structured Content at Enterprise Scale) tend to have exactly the search problem dedicated engines exist for: thousands of pages, multiple languages, and content editors who expect search results to respect the same permissions and page tree structure as the rest of the site.

The apache-solr-for-typo3 extension indexes the page tree directly, respecting access restrictions so a search never surfaces a page a given visitor shouldn’t see:

composer require apache-solr-for-typo3/solr
plugin.tx_solr {
  solr {
    host = solr.example.com
    port = 8983
    scheme = https
  }
  search {
    faceting = 1
    faceting.facets {
      contentType {
        field = type
      }
    }
  }
}

Faceted search, letting visitors filter results by content type, department, or date without writing custom filtering logic, is largely configuration once Solr is connected, not custom PHP.

When to reach for this

Enterprise TYPO3 sites where search quality and correctness (respecting multilingual content, page permissions, and faceted filtering) genuinely matter to the organization, not just a nice-to-have search box.

When it’s the wrong fit

A smaller TYPO3 site where the built-in indexed search extension is sufficient, and running a separate Solr or Elasticsearch cluster would be infrastructure the project doesn’t need yet.

Under the hood: The Solr integration mirrors TYPO3’s own permission model when indexing, storing which user groups can see each document alongside its content. That’s what prevents search from becoming an accidental way to leak restricted content, a detail generic search integrations sometimes miss entirely.

WordPress: Search Plugins and When to Reach for Elasticsearch

WordPress’s built-in search is a LIKE-based SQL query against post titles and content, and it shows: no relevance ranking worth the name, no typo tolerance, and it slows down noticeably as a site’s content grows into the thousands of posts.

The fastest fix is a plugin that replaces the built-in query with something better, without touching template code:

wp plugin install relevanssi --activate

Relevanssi reindexes existing content and takes over the search query, adding relevance scoring and fuzzy matching with essentially no code changes required.

For larger sites, or ones that need faceted filtering (search by category and price range together, for instance), an Elasticsearch-backed plugin swaps the search backend entirely:

wp plugin install elasticpress --activate
wp elasticpress index --setup
$args = [
    's' => 'wireless mouse',
    'post_type' => 'product',
];
$query = new WP_Query($args); // ElasticPress intercepts this transparently

The application code barely changes: WP_Query still works the same way, and ElasticPress just answers it from Elasticsearch instead of MySQL underneath.

When to reach for this

Relevanssi for most sites where search quality, not raw scale, is the complaint. ElasticPress once the site has enough content, or complex enough filtering needs, that even a smarter SQL query isn’t going to be fast or flexible enough.

When it’s the wrong fit

A small site with a few dozen pages, where the default search is already fast enough and the complaint doesn’t actually exist yet. Don’t add search infrastructure ahead of an actual problem.

Under the hood: WP_Query is WordPress’s central abstraction for “get me some posts,” used by nearly every theme and plugin. ElasticPress works by hooking into that same class and rerouting its query at the last moment, which is why adopting it rarely requires rewriting existing theme code.

Shipping Background Work

Sending the confirmation email later, resizing the uploaded image later, generating the monthly report at 2 a.m. without a human watching: anything that shouldn’t block the user’s request, or that needs to happen on a schedule rather than in response to one, is background work. Doing this well means a queue and a worker process, not a sleep() call and hope.

Laravel: Queues and Horizon

Any slow or non-essential piece of work in a Laravel request can become a Job, pushed onto a queue and processed by a separate worker process, so the user’s request finishes immediately instead of waiting on it.

php artisan make:job SendWelcomeEmail
class SendWelcomeEmail implements ShouldQueue
{
    use Queueable;

    public function __construct(private User $user) {}

    public function handle(): void
    {
        Mail::to($this->user)->send(new WelcomeEmail($this->user));
    }
}

// dispatching it
SendWelcomeEmail::dispatch($user);
php artisan queue:work

That last command is a worker process, typically kept running by a process manager like Supervisor, pulling jobs off the queue (Redis, in most production setups) and executing them.

Horizon adds a dashboard on top of Redis queues: throughput, failed jobs, retry controls, and per-queue metrics, without needing a separate monitoring tool.

composer require laravel/horizon
php artisan horizon:install
php artisan horizon

Failed jobs retry automatically with backoff, and permanently failed ones land in a failed_jobs table for inspection rather than vanishing silently.

When to reach for this

Anything that shouldn’t block a web request: sending email, processing an uploaded file, calling a slow third-party API, generating a report. If a user would notice the delay, it belongs on a queue.

When it’s the wrong fit

Work that genuinely needs to complete before the response, like validating a form before saving it. Queuing that just adds latency and complexity where synchronous code was already correct.

Under the hood: Jobs are serialized (usually with PHP’s native serialize()) before being stored in the queue, which is why job classes should only hold simple, serializable properties like a model ID rather than large objects or open resources; the worker process deserializes and reconstructs the job from scratch when it runs.

Symfony: The Messenger Component

Messenger is Symfony’s message bus: the same abstraction handles background jobs (like Laravel’s queues) and messaging between different parts of an application, or even between separate services, through one consistent interface.

composer require symfony/messenger
final class SendWelcomeEmail
{
    public function __construct(public readonly int $userId) {}
}

#[AsMessageHandler]
final class SendWelcomeEmailHandler
{
    public function __invoke(SendWelcomeEmail $message): void
    {
        $user = $this->users->find($message->userId);
        $this->mailer->send(new WelcomeEmail($user));
    }
}

// dispatching it, anywhere in the app
$this->bus->dispatch(new SendWelcomeEmail($user->getId()));
# config/packages/messenger.yaml
framework:
  messenger:
    transports:
      async: '%env(MESSENGER_TRANSPORT_DSN)%'
    routing:
      App\Message\SendWelcomeEmail: async
php bin/console messenger:consume async

Because messages and their handlers are decoupled through the bus, the same SendWelcomeEmail message could later be routed to a completely different transport (a message queue shared with another service, for instance) by changing configuration rather than application code.

When to reach for this

Any Symfony application needing background processing, especially when the project might eventually need actual service-to-service messaging, since the same component handles both without a second tool.

When it’s the wrong fit

A single, small script that just needs to do one slow thing occasionally. Reaching for a full message bus for a single cron job is more ceremony than the problem needs; a plain Console command run on a schedule is enough.

Under the hood: Messenger routes messages to handlers using PHP’s type system: the #[AsMessageHandler] attribute plus the type-hint on __invoke() tells Symfony which messages a handler accepts, so adding a new message type is a matter of defining a class, not registering it in a lookup table by hand.

WordPress: WP-Cron and the Action Scheduler

WP-Cron is WordPress’s built-in scheduling system, and it comes with a well-known quirk worth knowing before you rely on it: it doesn’t run on a real system timer. Instead, it checks whether any scheduled task is due every time a visitor loads a page, which means a site with no traffic can silently stop running its scheduled tasks on time.

add_action('daily_report_hook', function () {
    // generate and email the report
});

if (!wp_next_scheduled('daily_report_hook')) {
    wp_schedule_event(time(), 'daily', 'daily_report_hook');
}

The standard fix, for anything that actually matters, is disabling WordPress’s page-load trigger and calling it from a real system cron job instead:

// wp-config.php
define('DISABLE_WP_CRON', true);
# real crontab entry, running every 5 minutes
*/5 * * * * curl https://example.com/wp-cron.php?doing_wp_cron >/dev/null 2>&1

For anything beyond simple scheduled hooks, especially queued background work like sending bulk emails or processing an import, the Action Scheduler library (bundled with WooCommerce, but usable standalone) adds a proper queue with retries and logging on top of the same underlying idea:

as_schedule_single_action(time(), 'process_import_batch', ['batch_id' => 42]);

add_action('process_import_batch', function ($batch_id) {
    // process it
});

When to reach for this

WP-Cron with a real system crontab for anything time-sensitive: scheduled reports, subscription renewals, cache warming. Action Scheduler once you need retries, batching, or visibility into whether a background task actually succeeded.

When it’s the wrong fit

High-frequency or high-reliability background processing, where Laravel’s queue system or Symfony Messenger, both built around a real queue backend from the start, offer a more solid foundation than WordPress’s page-load-triggered model.

Under the hood: WP-Cron’s “check on every page load” design is a direct consequence of typical shared WordPress hosting historically not allowing users to configure real system cron jobs. It’s a reasonable workaround for that constraint, not a design flaw exactly, but one worth overriding the moment your hosting does allow real cron.

Shipping an AI Feature This Sprint

“Can we add a chatbot,” “can search understand natural language,” “can it summarize this for the user”: AI features arrive as small, bolt-on requests far more often than as a rewrite of the whole product. Adding one to an existing PHP app rarely means running your own model. It almost always means calling an API and handling the response well.

Laravel: Prism and the OpenAI/Anthropic PHP Clients

Prism is a Laravel-native package for talking to large language models (OpenAI, Anthropic, and others) behind one consistent API, so a feature you build against one provider isn’t locked to it.

composer require prism-php/prism
use Prism\Prism\Prism;
use Prism\Prism\Enums\Provider;

$response = Prism::text()
    ->using(Provider::Anthropic, 'claude-sonnet')
    ->withPrompt("Summarize this support ticket in two sentences:\n\n{$ticket->body}")
    ->generate();

echo $response->text;

Swapping providers, comparing cost or quality between Anthropic and OpenAI for the same feature, is a one-line change:

Prism::text()->using(Provider::OpenAI, 'gpt-4o')->withPrompt($prompt)->generate();

Structured output, useful for anything that needs to feed the result back into your database rather than just display it, is a schema instead of hand-parsed text:

$response = Prism::structured()
    ->using(Provider::Anthropic, 'claude-sonnet')
    ->withSchema($ticketClassificationSchema)
    ->withPrompt("Classify this ticket: {$ticket->body}")
    ->generate();

$category = $response->structured['category'];

For projects that want a thinner layer with less abstraction, the official openai-php/client and anthropic-php packages call each provider’s API directly, useful when a feature genuinely only needs to work with one provider.

When to reach for this

A specific, bounded feature (summarization, classification, a support chatbot, content suggestions) added to an app that’s otherwise a normal Laravel product.

When it’s the wrong fit

A feature that needs the model to act on live, private data at request time in a way an API call alone can’t provide, which usually points toward a retrieval step (searching your own data first, then including it in the prompt) rather than a reason to avoid this approach entirely.

Under the hood: Prism’s provider abstraction is a PHP interface, the same pattern behind Flysystem’s storage adapters: one contract, several interchangeable implementations, so your application code depends on the contract rather than a specific vendor’s SDK.

Symfony: The AI Bundle

Symfony’s AI Bundle brings the same “talk to a model behind a clean interface” idea into Symfony’s own conventions: configuration-driven setup, dependency injection, and a platform abstraction so the underlying model provider isn’t hard-wired into your business logic.

composer require symfony/ai-bundle
# config/packages/ai.yaml
ai:
  platform:
    anthropic:
      api_key: '%env(ANTHROPIC_API_KEY)%'
  agent:
    support_assistant:
      model: 'claude-sonnet'
      system_prompt: 'You are a concise, friendly support assistant.'
final class TicketSummaryController
{
    public function __construct(private AgentInterface $supportAssistant) {}

    public function summarize(Ticket $ticket): Response
    {
        $result = $this->supportAssistant->call(
            new UserMessage("Summarize this ticket:\n\n{$ticket->getBody()}"),
        );

        return new JsonResponse(['summary' => $result->getContent()]);
    }
}

Because the agent is configured, not constructed by hand, swapping the model or provider for a given feature is a YAML change, and Symfony’s own testing tools (see Shipping Confidence) can substitute a fake agent in tests without ever calling a real API.

When to reach for this

A Symfony project adding an AI-powered feature where consistency with the rest of the app’s configuration and dependency injection conventions matters, or where the team wants an easy path to swap providers or mock the AI call entirely in tests.

When it’s the wrong fit

A tiny script or a non-Symfony project, where pulling in the bundle’s configuration layer is more setup than calling a provider’s HTTP API directly with Guzzle.

Under the hood: The bundle’s AgentInterface is, again, ordinary interface-based dependency injection: Symfony’s service container hands your controller whatever concrete agent implementation is configured, which is exactly the same mechanism it uses to inject a database connection or a logger.

WordPress: AI Plugins and When to Call an API Instead

The fastest AI feature you’ll ever ship on WordPress is one you don’t write at all. Plugins already exist for the common requests: an AI writing assistant in the block editor, automatic alt-text generation for images, an on-site chatbot trained on the site’s own content.

wp plugin install ai-engine --activate

Most of these plugins work by holding your own API key for a provider (OpenAI, Anthropic) and wrapping it in a WordPress-friendly settings screen and editor integration, rather than reinventing the underlying model access.

For something a plugin doesn’t cover, calling a provider’s API directly from a small custom plugin is often only a few lines, using WordPress’s own HTTP API rather than a separate library:

add_action('save_post_product', function ($post_id) {
    $description = get_post_field('post_content', $post_id);

    $response = wp_remote_post('https://api.anthropic.com/v1/messages', [
        'headers' => [
            'x-api-key' => get_option('anthropic_api_key'),
            'content-type' => 'application/json',
        ],
        'body' => wp_json_encode([
            'model' => 'claude-sonnet',
            'max_tokens' => 100,
            'messages' => [['role' => 'user', 'content' => "Write a one-line SEO summary for: {$description}"]],
        ]),
    ]);

    $data = json_decode(wp_remote_retrieve_body($response), true);
    update_post_meta($post_id, 'ai_seo_summary', $data['content'][0]['text']);
});

When to reach for this

An existing plugin for common, well-trodden requests like writing assistance or alt-text. A small custom plugin using wp_remote_post() for anything specific to the site’s own content model.

When it’s the wrong fit

A feature that’s really a full custom application (a recommendation engine, a complex multi-step AI workflow) wearing a thin WordPress skin. At that point, a proper framework’s tooling, like Prism or Symfony’s AI Bundle, gives you far more structure than hooking raw API calls into WordPress actions.

Under the hood: wp_remote_post() is WordPress core’s own HTTP client, built to work consistently across different server environments (some hosts restrict which PHP HTTP functions are available), which is why WordPress code conventionally avoids calling curl or file_get_contents() directly for outbound requests.

Shipping Multi-Language, Multi-Site

One codebase, several languages, several national subsidiaries with slightly different content: a request that sounds like a translation problem and is usually, underneath, a content architecture problem. Getting the structure right (how a translated page relates to its original, who can edit what) matters more than the translation mechanism itself.

TYPO3: Multilingual Content Trees Done Properly

TYPO3 has treated multiple languages as a first-class concept in its page tree since long before most competitors, and it shows in how cleanly the relationship between an original page and its translations is modeled: not a separate copy of the site, but connected records within the same structure.

// config/sites/main/config.yaml
languages:
  - title: English
    languageId: 0
    locale: en_US.UTF-8
    base: /
  - title: French
    languageId: 1
    locale: fr_FR.UTF-8
    base: /fr/
    fallbackType: strict

Editors translate content directly from the backend’s language view, page by page, with a clear indicator of which translations are complete, outdated (because the original changed since), or missing entirely:

./vendor/bin/typo3 language:update

fallbackType: strict means an untranslated page in French simply doesn’t exist in that language rather than silently showing English content, an explicit, deliberate choice rather than a default that quietly ships half-translated pages to visitors.

When to reach for this

Large institutional sites needing genuine multilingual content management, where editors in different regions manage their own language’s content, and the relationship between an original and its translations needs to survive years of edits by different people.

When it’s the wrong fit

A small site needing only a couple of static translated pages, where WPML on WordPress or a simpler translation plugin gets there with far less initial setup.

Under the hood: TYPO3 stores translated content as separate database records connected by a shared identifier (l10n_parent), rather than one record with multiple language columns. That structural choice is what lets a page have three finished translations and two missing ones simultaneously, tracked cleanly rather than crammed into one row’s worth of columns.

WordPress: Multisite and WPML

WordPress actually has two different answers here, for two different questions, and picking the wrong one is a common, expensive mistake.

“We have several distinct sites that should share users and plugins” (several national subsidiaries with genuinely different content and design, not just a translation of the same pages) is what WordPress Multisite solves: one WordPress install running a network of separate sites, sharing a codebase and user base but not content.

wp core multisite-convert
wp site create --slug=fr --title="Example France"
wp site create --slug=de --title="Example Germany"

“We have one site that needs to exist in several languages” (the same pages, translated) is a different problem, and Multisite is the wrong tool for it: it would mean maintaining separate copies of every page by hand. That’s what a translation plugin like WPML solves instead, keeping one site with linked translations of each page:

wp plugin install sitepress-multilingual-cms --activate
wp wpml language add fr de
// getting a translated post ID for the current language
$translated_id = apply_filters('wpml_object_id', $post_id, 'post', true);

When to reach for each

Multisite when the sites genuinely differ beyond translation: different design, different content strategy, different admin teams per country. WPML when it’s truly the same site, the same content, just needing to exist in more than one language.

When it’s the wrong fit

Using Multisite as a translation tool, or using WPML to try to run what are actually separate, independently managed sites. Both mistakes are common, and both get expensive to unwind once a year of content has accumulated on the wrong structure.

Under the hood: Multisite works by adding a blog_id to WordPress’s core tables and routing requests through a network-aware bootstrap, essentially running several logically separate installs against shared code. WPML instead adds its own linking table between translated posts, leaving WordPress’s core single-site architecture untouched.

Symfony: The Translation Component

There’s a distinction worth being precise about: everything else in this chapter is about translating content, what an editor wrote. Symfony’s Translation component solves a different, narrower problem: translating an application’s own interface, the labels, buttons, and error messages that are part of the code, not the content.

composer require symfony/translation
# translations/messages.en.yaml
welcome_message: "Welcome back, %name%!"
cart.empty: "Your cart is empty."
# translations/messages.fr.yaml
welcome_message: "Content de vous revoir, %name% !"
cart.empty: "Votre panier est vide."
<h1>{{ 'welcome_message'|trans({'%name%': user.firstName}) }}</h1>
$message = $translator->trans('cart.empty');

The active locale is typically set per request, from a URL prefix, a user preference, or the Accept-Language header, and everything wrapped in trans() follows it automatically.

When to reach for this

Any Symfony application whose interface itself needs to support multiple languages: labels, validation error messages, email templates, navigation. This is standard practice for any Symfony app with international users, not an advanced feature.

When it’s the wrong fit

Translating editorial content (blog posts, product descriptions) rather than interface strings. That’s the problem TYPO3’s multilingual content trees or WPML solve; this component has no concept of “content,” only of message keys.

Under the hood: Translation files are loaded once and cached as compiled PHP arrays in Symfony’s cache directory, so looking up a translated string in production is an array lookup, not a file read or a database query, even with hundreds of keys across a dozen languages.

Shipping Confidence

“It works on my machine” is not a feature. Confidence, the actual, checkable kind that comes from a test suite that catches regressions, a static analyzer that catches whole categories of bugs before they run, and a scan that flags a known-vulnerable dependency before it ships, is a feature too. It’s the one where the bug gets caught before your user finds it, and it belongs in every project in this book, not just the ones with time left over at the end.

Laravel: Pest, Larastan, and composer audit

Pest is a testing framework built on top of PHPUnit, designed so tests read close to plain English, lowering the barrier to actually writing them.

composer require pestphp/pest --dev --with-all-dependencies
php artisan pest:install
it('rejects an order with no items', function () {
    $order = Order::factory()->create();

    expect(fn () => $order->submit())
        ->toThrow(EmptyOrderException::class);
});

it('applies a discount code correctly', function () {
    $order = Order::factory()->create(['total' => 100]);

    $order->applyDiscount('SAVE10');

    expect($order->total)->toBe(90.0);
});
php artisan test

Larastan wraps PHPStan (see Symfony’s PHPStan/Psalm for the framework-agnostic version) with Laravel-specific type knowledge, catching bugs like calling a method that doesn’t exist on a model, or passing the wrong type to a job, without ever running the code.

composer require larastan/larastan --dev
# phpstan.neon
includes:
    - vendor/larastan/larastan/extension.neon
parameters:
    level: 6
    paths: [app]
vendor/bin/phpstan analyse

composer audit checks installed dependencies against a database of known vulnerabilities, catching the moment a package you depend on gets a CVE, before an attacker finds it first.

composer audit

When to reach for this

Every Laravel project past the prototype stage. Tests and static analysis are cheapest to add early and most expensive to retrofit onto a codebase that’s already grown without them.

When it’s the wrong fit

There isn’t really one here; the honest failure mode is skipping this chapter under deadline pressure, not a case where it’s the wrong tool.

Under the hood: PHPStan (and by extension Larastan) works by reading your code’s type hints, including PHP’s union types, readonly properties, and enums, and reasoning about what’s possible without executing anything. The stricter your type hints, the more bugs it can catch before a single test runs.

Symfony: PHPUnit, PHPStan/Psalm, and Rector

Symfony’s testing story starts with plain PHPUnit, with a bridge package smoothing over the framework-specific parts: booting the kernel, making requests against it, and querying Doctrine in tests.

composer require symfony/test-pack --dev
class OrderControllerTest extends WebTestCase
{
    public function testSubmittingAnEmptyOrderFails(): void
    {
        $client = static::createClient();
        $client->request('POST', '/orders/1/submit');

        $this->assertResponseStatusCodeSame(422);
    }
}
php bin/phpunit

PHPStan (or its close cousin Psalm) analyzes code without running it, catching type errors, unreachable code, and incorrect method calls before they become runtime bugs. Both work equally well outside Symfony too, on the standalone components covered earlier in this book.

composer require phpstan/phpstan --dev
vendor/bin/phpstan analyse src --level=8

Rector automates upgrades and refactors: point it at a target PHP or Symfony version, and it rewrites your codebase’s syntax to match, mechanically, across thousands of files at once.

composer require rector/rector --dev
// rector.php
return RectorConfig::configure()
    ->withPaths([__DIR__ . '/src'])
    ->withSets([SymfonySetList::SYMFONY_64]);
vendor/bin/rector process

When to reach for this

Any Symfony project, at any size. Rector specifically earns its place the moment a major Symfony or PHP upgrade looms and hand-editing every affected file isn’t realistic.

When it’s the wrong fit

Running Rector against a codebase with no test suite at all is riskier than it should be; pair it with at least the tests described here so its automated changes have something checking they didn’t break behavior.

Under the hood: Rector works by parsing your code into an AST (Abstract Syntax Tree), the same structure PHP’s own engine builds internally before execution, applying a rule to it, and printing modified PHP back out. It’s mechanical code transformation, not a language model guessing at intent.

WordPress: PHPUnit, PHPCS/WPCS, and WPScan

Testing a WordPress plugin or theme means testing against WordPress itself, not a lightweight mock of it, which is exactly what the official test suite scaffold sets up.

wp scaffold plugin-tests my-plugin
cd wp-content/plugins/my-plugin
composer install
./bin/install-wp-tests.sh wordpress_test root '' localhost latest
class DiscountCalculationTest extends WP_UnitTestCase
{
    public function test_bulk_discount_applies_over_ten_items(): void
    {
        $product = $this->factory->post->create(['post_type' => 'product']);

        $price = apply_filters('calculate_bulk_price', 100, 12);

        $this->assertEquals(90, $price);
    }
}

PHP_CodeSniffer with the WordPress Coding Standards (WPCS) ruleset catches both style violations and a meaningful number of common security mistakes (unescaped output, unsanitized input) specific to how WordPress plugins tend to go wrong.

composer require --dev squizlabs/php_codesniffer wp-coding-standards/wpcs
vendor/bin/phpcs --standard=WordPress my-plugin.php

WPScan checks a running site’s installed plugins and themes against a database of known WordPress-specific vulnerabilities, catching the moment something you depend on gets a disclosed CVE.

wpscan --url https://example.com --api-token YOUR_TOKEN

When to reach for this

Any custom plugin or theme with real logic in it, not just template markup. Security-sensitive code (anything handling user input or payments) especially benefits from WPCS’s built-in checks for unescaped output.

When it’s the wrong fit

A purely visual child theme with no custom PHP logic has little to test; the effort is better spent where actual code, not just markup, exists.

Under the hood: WP_UnitTestCase runs each test inside a database transaction that’s rolled back afterward, so tests can freely create posts, users, and options without leaving the test database dirty for the next test, the same isolation strategy most PHP testing frameworks use.

Cross-Ecosystem: SAST, Dependency Scanning, and CI Gates

Everything so far in this chapter has been framework-specific. This page covers what works identically no matter which stack from this book you’re shipping with, and how to wire it together so a broken or vulnerable build never quietly reaches production.

Static analysis as lightweight SAST: PHPStan and Psalm, already covered per framework earlier in this chapter, double as a first line of static application security testing (SAST), catching things like SQL built from unsanitized input or a variable used before it’s guaranteed to be set.

GitHub CodeQL performs deeper semantic security analysis, specifically tracing how untrusted input flows through the code toward a dangerous sink (a raw SQL query, an eval(), an unescaped output), rather than just checking types.

# .github/workflows/codeql.yml
- uses: github/codeql-action/init@v3
  with:
    languages: php
- uses: github/codeql-action/analyze@v3

Dependabot watches your composer.lock and opens a pull request automatically the moment a dependency has a known vulnerability with a fix available.

# .github/dependabot.yml
version: 2
updates:
  - package-ecosystem: "composer"
    directory: "/"
    schedule: { interval: "weekly" }

Snyk ($) covers similar ground to Dependabot with a paid product’s polish: a dashboard, license compliance checks, and coverage across more than just Composer dependencies, in exchange for a subscription past its free tier.

Wiring it into CI

The point of all of this is a gate, not a report nobody reads:

# .github/workflows/ci.yml
jobs:
  quality:
    steps:
      - run: composer audit
      - run: vendor/bin/phpstan analyse
      - run: vendor/bin/pest # or php bin/phpunit

If any step fails, the pull request can’t merge. That’s the actual feature: not that the tools exist, but that a broken or vulnerable build is structurally prevented from reaching production, rather than relying on someone remembering to run a check by hand.

Under the hood: CodeQL’s real strength is that it models data flow, tracing a value from where it enters the program (a $_GET parameter, a form field) to where it’s used, rather than checking each line in isolation. That’s a meaningfully different, and more expensive, kind of analysis than the type-checking PHPStan and Psalm perform.

Catching It in Production: Sentry and Flare ($)

Tests catch what you thought to test for. Static analysis catches what the type system can prove. Neither catches the thing a real user does at 11 p.m. that nobody anticipated. That’s what error tracking is for: confidence doesn’t stop at the deploy button; it extends into knowing, immediately, when something breaks for a real person, with a full stack trace instead of a support email that just says “it’s broken.”

Sentry works across every framework covered in this book, with SDKs for Laravel, Symfony, and plain PHP alike.

composer require sentry/sentry-laravel
php artisan sentry:publish --dsn=your-dsn-here
try {
    $order->submit();
} catch (\Throwable $e) {
    \Sentry\captureException($e);
    throw $e;
}

Uncaught exceptions are captured automatically once installed; the manual call above is for cases where you want to log something that’s handled gracefully but still worth knowing about.

Flare ($), from the Laravel ecosystem’s Spatie, is more tightly scoped to Laravel specifically, with deeper framework-aware context in every report: which route, which query, which job, alongside the stack trace.

composer require facade/ignition

Pricing

Sentry has a genuinely usable free tier for small projects, scaling to a paid plan as event volume grows. Flare is a paid product with a free trial and no meaningful free tier for ongoing use, which is why both carry the $ here even though Sentry’s is more conditional.

When to reach for this

Any application in real production use, full stop. The alternative, waiting for a user to report a bug they can’t fully describe, costs far more debugging time than the subscription.

When it’s the wrong fit

A prototype or an internal tool with a handful of users who’ll simply ping you directly when something breaks; the overhead of setting this up isn’t justified yet.

Under the hood: These tools hook into PHP’s own exception handler and error reporting mechanism (set_exception_handler(), set_error_handler()), which is why installing them is typically a few lines of bootstrap code rather than wrapping every function call in your application by hand.

Shipping It Fast, At Scale

Most features in this book ship fine on ordinary PHP: a fresh process per request, a database query, a response, done. Then traffic grows, or a specific page turns out to be slow, and “it works” quietly stops being enough. This chapter is about that second phase: not premature optimization, but the concrete tools for when performance becomes a real, measured requirement.

FrankenPHP and Laravel Octane: Worker Mode Performance

Traditional PHP rebuilds your entire application, framework included, from scratch on every single request: booting the container, loading configuration, all of it, discarded the moment the response is sent. That’s a large part of why PHP earned a reputation for being slower than runtimes that keep an application resident in memory.

Worker mode changes that: boot the application once, then handle many requests against that same booted instance, resetting only what needs resetting between them.

composer require laravel/octane
php artisan octane:install --server=frankenphp
php artisan octane:start

FrankenPHP is a PHP application server, written in Go, that runs PHP in worker mode natively (as well as classic mode), with built-in HTTPS and no separate web server needed:

frankenphp php-server --worker /path/to/public/index.php

The performance gain is substantial for applications that were previously bottlenecked on framework boot time rather than actual business logic: benchmarks commonly show several times the throughput of classic PHP-FPM for the same application code, unchanged.

The tradeoff is real and worth stating plainly: global state that used to reset automatically between requests (a static property, a singleton holding request-specific data) can now leak between requests if you’re not careful, since the same PHP process handles many requests in sequence rather than starting fresh each time.

When to reach for this

An application under real, measured load where profiling (see Blackfire) shows framework bootstrap overhead, not your own business logic, as the actual bottleneck.

When it’s the wrong fit

A low-traffic site or internal tool. Worker mode adds a category of bug (accidental state leakage between requests) that isn’t worth taking on before you actually need the throughput.

Under the hood: This is the exact same problem Reverb’s WebSocket server solves with Fibers, approached from the opposite direction: instead of one process holding many concurrent connections, worker mode keeps one process alive across many sequential requests. Both exist because PHP’s traditional “die after every request” model, once a limitation, became optional.

TYPO3: The Built-In Caching Framework

TYPO3’s caching framework is worth studying even outside a TYPO3 project, because it solves caching as a genuinely layered problem rather than a single on/off switch: page output, individual content elements, database query results, and configuration each get their own cache, each with its own invalidation rules.

# config/system/settings.php
$GLOBALS['TYPO3_CONF_VARS']['SYS']['caching']['cacheConfigurations']['pages'] = [
    'backend' => \TYPO3\CMS\Core\Cache\Backend\Typo3DatabaseBackend::class,
    'options' => ['defaultLifetime' => 86400],
];

Editors don’t manage cache invalidation directly; TYPO3 clears exactly the affected caches automatically when content changes, rather than forcing a blunt “clear everything” after every edit:

$cacheManager = GeneralUtility::makeInstance(CacheManager::class);
$cacheManager->getCache('pages')->flushByTag('pageId_' . $pageId);

That tag-based invalidation, flushing only what actually changed, is what makes aggressive caching safe on a large site: a typo fix on one page doesn’t force every other page to be regenerated.

For high-traffic sites, TYPO3 layers a reverse proxy cache (typically Varnish) in front of all of this, serving fully rendered pages without PHP running at all for the common case of an anonymous visitor requesting unchanged content.

When to reach for this

Large TYPO3 sites under real traffic, where page generation cost adds up across thousands of pages, and where content updates happen often enough that “just clear the whole cache” would defeat the purpose.

When it’s the wrong fit

A low-traffic site where the default caching is already fast enough; tuning cache lifetimes and tags for a site nobody’s straining is effort spent on a problem that doesn’t exist yet.

Under the hood: Tag-based cache invalidation works by associating each cached item with one or more tags (a page ID, a content type) at write time, so flushing “everything tagged pageId_42” is a targeted operation rather than a full cache wipe, the same principle behind cache invalidation strategies in most well-designed caching systems, PHP or otherwise.

Nextcloud: Scaling a Self-Hosted Platform

A self-hosted platform like Nextcloud faces a scaling question most cloud SaaS products never expose to their customers: someone on your team has to actually operate the growth curve, not just pay for a bigger plan.

The standard path, in order, as an organization grows past a single small server:

# 1. Move sessions and caching to Redis instead of the filesystem
php occ config:system:set redis host --value=redis.internal
php occ config:system:set memcache.distributed --value='\OC\Memcache\Redis'

# 2. Move file storage to S3-compatible object storage instead of local disk
php occ config:system:set objectstore class --value='\OC\Files\ObjectStore\S3'

# 3. Add read replicas and split heavy background jobs onto dedicated workers
php occ background:cron

Each step addresses a specific, identifiable bottleneck rather than being applied preemptively: Redis when concurrent users start contending on file-based session locks, object storage when local disk fills up or a single server’s I/O becomes the limit, background job separation when scheduled tasks (like the file preview generation Nextcloud does automatically) start competing with live user requests for CPU.

For organizations that don’t want to own this operational curve at all, the officially supported route is the All-in-One Docker image (see Nextcloud: All-in-One Docker Deployment) or Nextcloud’s own hosted offering, trading operational ownership for a subscription.

When to reach for this

Any self-hosted Nextcloud instance that’s grown from a pilot team to an organization-wide rollout, where the default single-server setup starts showing real, measured strain rather than hypothetical concern.

When it’s the wrong fit

A small team’s instance well within the comfortable range of a single well-specced server. Adding Redis and object storage ahead of an actual bottleneck is operational complexity paid for early, for no measured benefit.

Under the hood: Nextcloud’s storage abstraction is, again, built on Flysystem-style adapters (see League/Flysystem): the application code that reads and writes files doesn’t change when the backend switches from local disk to S3, because it was never written against “the filesystem” directly in the first place.

Blackfire ($): Finding the Actual Bottleneck

Every performance fix in this chapter (worker mode, caching, scaling infrastructure) assumes you already know what’s slow. Guessing wrong wastes far more time than the fix itself: adding a cache in front of a query that was never the bottleneck fixes nothing. Blackfire profiles a real request and shows exactly where the time and memory went, function by function.

composer require blackfire/php-sdk --dev
blackfire run php artisan test

Or, for a live request, wrapping it directly:

blackfire curl https://example.com/checkout

The resulting profile shows a call graph: which function called which, how long each took, and how many times each ran. It immediately surfaces the usual real-world culprits: an N+1 database query loop, an uncached external API call repeated needlessly, a template rendering step that turns out to dominate the request despite looking trivial in the code.

// what profiling often reveals: 200 queries where one would do
foreach ($orders as $order) {
    echo $order->customer->name; // N+1: one query per order
}

// the fix, informed by the profile rather than a guess
$orders = Order::with('customer')->get();

Pricing

Blackfire has a free tier for individual use, with paid plans for team collaboration and continuous profiling in CI, which is what earns it the $ for anything beyond solo, occasional use.

When to reach for this

The moment a specific page or endpoint is reported slow and the cause isn’t obvious from reading the code. Profile first, then fix; fixing first and profiling to confirm it worked is backwards and usually wastes a cycle.

When it’s the wrong fit

Optimizing a page nobody has complained about and no metric has flagged. Profiling tools are for measured problems, not anxiety about hypothetical ones.

Under the hood: Blackfire works via a PHP extension that hooks into the Zend Engine’s function call mechanism, timing every function call transparently, which is why profiling adds measurable overhead and is normally run against a specific request on demand rather than left on for all production traffic.

Shipping to Production

A feature isn’t shipped until it’s live, and “live” means someone, possibly you, is now responsible for it staying up, staying patched, and surviving the traffic it gets. This chapter isn’t about writing more code; it’s about the last mile between “works locally” and “a real client is using this.”

Laravel: Forge and Vapor ($)

Laravel’s own team offers two deployment products, aimed at different infrastructure preferences.

Forge provisions and manages a traditional server (on your own AWS, DigitalOcean, or Hetzner account) with zero manual server administration: it configures Nginx, PHP-FPM, a database, SSL certificates, and queue workers, and gives you a dashboard for deploying, monitoring, and scaling.

# after connecting a server through Forge's dashboard, deployment is a git push
git push forge main

Forge deploy scripts run whatever your project needs on each push, editable per site:

cd /home/forge/example.com
git pull origin main
composer install --no-dev --optimize-autoloader
php artisan migrate --force
php artisan queue:restart

Vapor is a different model entirely: serverless deployment onto AWS Lambda. There’s no server to patch or scale manually; the application runs only while handling a request, and scales automatically to zero or to thousands of concurrent requests.

composer require laravel/vapor-cli --dev
vapor deploy production

Pricing

Both are paid subscriptions on top of whatever infrastructure they provision (Forge charges per server managed; Vapor charges based on usage plus the underlying AWS costs), which is what earns them the $.

When to reach for each

Forge for a traditional app that benefits from a persistent server (background workers, WebSocket connections via Reverb). Vapor for unpredictable or spiky traffic where paying only for actual usage, and never thinking about server capacity, outweighs the constraints serverless imposes.

When it’s the wrong fit

A team that already has infrastructure expertise and existing AWS/cloud tooling might prefer managing deployment directly rather than paying for Forge’s convenience layer on top of it.

Under the hood: Vapor’s serverless model works because Laravel’s request lifecycle was always stateless by design: nothing assumes the same process handles the next request, which is exactly the assumption Lambda’s “cold start per invocation” model requires to work at all.

Cloud Hosting: AWS, GCP, and Azure the Pragmatic Way

Sometimes the deployment decision isn’t yours to make: the client already has an AWS, Google Cloud, or Azure contract, often for compliance, procurement, or existing-infrastructure reasons that have nothing to do with what’s easiest for you. In that situation, the pragmatic move is picking the option on that specific cloud that costs the least operational effort, not hand-rolling infrastructure you don’t need.

AWS: App Runner takes a container and runs it with automatic scaling and load balancing, with far less configuration than a hand-built EC2/ECS setup.

aws apprunner create-service \
  --service-name my-app \
  --source-configuration file://apprunner-config.json

Google Cloud: Cloud Run does the same job for a container on GCP, scaling to zero when idle, which suits a modest-traffic app well on a usage-based bill.

gcloud run deploy my-app --source . --platform managed --region us-central1

Azure: App Service supports PHP directly, without needing to containerize first, closer in spirit to traditional managed hosting than the container-first AWS and GCP options above.

az webapp up --runtime "PHP:8.3" --name my-app

A Dockerfile written once, using a standard php:8.3-fpm or FrankenPHP-based image, generally works across App Runner and Cloud Run with little to no change, since both just want a container that listens on a port.

When to reach for this

The client already has, or specifically requires, infrastructure on a particular cloud provider, and a framework-specific platform like Forge or a third-party PaaS like Platform.sh isn’t an option for procurement or compliance reasons.

When it’s the wrong fit

No existing cloud relationship, and no compliance requirement pointing at one. In that case, a framework-native option or a dedicated PaaS usually reaches production faster with less infrastructure knowledge required.

Under the hood: All three platforms ultimately run the same PHP-FPM or FrankenPHP process your local development environment does; a container is a container. The differences between them are entirely in deployment mechanics and billing, not in how PHP itself executes your code once it’s running.

Platform.sh ($): One Deploy Story for Several Frameworks

Platform.sh’s pitch is framework-agnostic infrastructure-as-code: one configuration format that works whether the project underneath is Laravel, Symfony, WordPress, TYPO3, or something else entirely, which makes it useful for an agency shipping across several of the stacks covered in this book without learning a different deployment platform for each one.

# .platform.app.yaml
name: app
type: php:8.3
dependencies:
  php:
    composer/composer: "^2"
relationships:
  database: "db:mysql"
hooks:
  build: composer install --no-dev --optimize-autoloader
  deploy: php artisan migrate --force
# .platform/services.yaml
db:
  type: mysql:8.0
git push platform main

Every push creates a full, isolated environment (its own database, its own storage) rather than deploying over a shared one, which makes previewing a feature branch as a real, running application a routine part of the workflow rather than a special setup.

Pricing

Platform.sh is a paid managed service, billed per project and environment, with no meaningful free tier for production use, which is what earns it the $.

When to reach for this

Agencies or teams managing multiple projects across different PHP frameworks and CMSes, where one consistent deployment workflow across all of them is worth more than any single platform’s framework-specific conveniences.

When it’s the wrong fit

A single project committed to one framework long-term, where a framework-native option like Forge or managed WordPress hosting is simpler and often cheaper for that one use case.

Under the hood: The per-branch environment model works because Platform.sh treats infrastructure itself as versioned configuration alongside your code, the same instinct as a composer.lock file pinning dependency versions, just extended to cover the database and services a deployment needs too.

WordPress: Managed Hosting Done Right (Kinsta, WP Engine $)

Generic hosting treats WordPress like any other PHP application. Managed WordPress hosting, from providers like Kinsta and WP Engine, is built around WordPress’s specific, well-known needs: aggressive object and page caching tuned for WordPress’s query patterns, one-click staging environments, automatic core and plugin updates on a schedule you control, and malware scanning specific to known WordPress plugin vulnerabilities.

# a typical managed-host workflow: staging, then push to production
wp @staging plugin update --all
wp @staging cache flush
# after review in the staging environment
wp @production deploy

The practical difference from self-managed hosting shows up during an incident: managed hosts typically include automatic backups with one-click restore, and support staff who already know WordPress’s failure modes specifically, rather than generic server support that has to learn the platform from your ticket.

Pricing

Both Kinsta and WP Engine are paid, tiered by traffic and site count, with no meaningful free tier, which is what earns this the $. The cost typically sits well above generic shared hosting and below the cost of an in-house team operating the same reliability and performance work manually.

When to reach for this

Any client-facing, revenue-generating, or otherwise important WordPress site, where downtime or a slow page has a real cost, and where nobody in-house wants to own WordPress-specific server tuning and security monitoring.

When it’s the wrong fit

A low-stakes personal blog or an internal tool, where generic, cheaper hosting is entirely sufficient and the specialized tooling of managed WordPress hosting goes largely unused.

Under the hood: Much of what these hosts offer is object caching, storing the results of expensive WordPress database queries in memory (via Redis or Memcached) so a popular page doesn’t re-run the same queries for every visitor, the same caching instinct behind TYPO3’s caching framework, pre-configured specifically for WordPress’s common query patterns.

Nextcloud: All-in-One Docker Deployment

Standing up Nextcloud correctly by hand means configuring a web server, PHP-FPM, a database, Redis, a reverse proxy with valid TLS, and a background cron job, each a separate opportunity to misconfigure something security-sensitive. The All-in-One (AIO) Docker image is Nextcloud’s own officially maintained answer: all of that, pre-wired, running as a coordinated set of containers.

docker run \
  --name nextcloud-aio-mastercontainer \
  --restart always \
  -p 8080:8080 \
  -v nextcloud_aio_mastercontainer:/mnt/docker-aio-config \
  -v /var/run/docker.sock:/var/run/docker.sock:ro \
  nextcloud/all-in-one:latest

Visiting the container’s web interface afterward walks through TLS certificate setup, choosing which optional apps to enable (including Talk’s high-performance backend, covered in Real-Time Features), and backup configuration, all managed through one dashboard rather than a dozen separate config files.

Updates to Nextcloud itself and to each enabled app are handled through the same interface:

# update the mastercontainer image, then trigger an update from its UI
docker pull nextcloud/all-in-one:latest
docker stop nextcloud-aio-mastercontainer
docker rm nextcloud-aio-mastercontainer
# re-run the original docker run command to restart it on the new image

When to reach for this

Self-hosting Nextcloud for real, ongoing use, whether for a small team or a full organization. The AIO image encodes a lot of hard-won operational knowledge about securing and maintaining a Nextcloud instance correctly, which is easy to get subtly wrong by hand.

When it’s the wrong fit

A quick local evaluation where the plain Docker image (see Nextcloud: A Ready-Made Drive) is faster to throw away, or an environment where Docker itself isn’t an option.

Under the hood: The mastercontainer pattern, one container that manages the lifecycle of several others via the Docker socket, is a form of orchestration lighter than Kubernetes but heavier than a single docker run, chosen specifically so updates to interdependent services (the app, the database, the proxy) happen in the right order automatically.

Where to Go From There

This book covered a lot of ground without ever asking you to commit to one stack permanently. That was deliberate: the pragmatic builder’s actual skill isn’t mastering one framework; it’s correctly matching a feature to the tool that ships it fastest, again and again, across different projects and different clients. This closing chapter is about keeping that skill sharp after you close the book.

Evaluating a New Stack in a Day

A new client, a new tool, a new framework you haven’t personally shipped anything in yet: this book’s chapters cover the options that exist today, but new ones will exist tomorrow, and clients don’t wait for the next edition. Here’s a repeatable way to size one up before committing real project time to it.

Morning: scaffold and orient

Run the tool’s own quickstart, exactly as documented, no shortcuts. Note how long it took, how much was decided for you automatically, and how much freedom you have left. (See Scaffolding a First Project in Under a Minute for what this looked like across the frameworks and CMSes covered in this book.)

Midday: build one real, small thing

Not a “hello world,” an actual feature: a login form, a data table, a single API endpoint. Time how long it takes and note where you got stuck searching documentation. That friction is the most honest signal you’ll get about what a real project in this tool will actually feel like.

Afternoon: check the edges

Look specifically for what this book calls out as decision-relevant in every chapter: is there an official, maintained path for testing (see Shipping Confidence)? For deployment (see Shipping to Production)? Is the community active enough that a stuck question gets answered in hours, not weeks (see Communities Worth Joining)?

Evening: decide against the actual brief, not the tool’s marketing

Every tool’s homepage claims to be fast, modern, and developer-friendly. None of that matters as much as whether it matches what Chapter 1 asked first: framework, CMS, or headless? And does this specific option fit the feature you were actually asked to build?

Under the hood: This same practice (quickstart, then a small real feature, then the edges) works for evaluating tools outside PHP entirely. What’s specific to this book is only the list of edges worth checking: PHP’s ecosystem has enough mature, well-trodden answers for testing, deployment, and community support that a new tool lacking any of them is worth treating as a real yellow flag, not a minor gap.

Communities Worth Joining

Documentation answers the question you knew to ask. A community answers the one you didn’t. Every ecosystem in this book has an active place where real practitioners answer real, specific problems, usually faster than a search engine will find you a current answer.

  • Laravel: the Laravel Discord and Laracasts forum are both active, and the official docs at laravel.com are unusually well maintained release over release.
  • Symfony: the Symfony Slack and the community forum linked from symfony.com are where most framework and component-specific questions get resolved.
  • WordPress: make.wordpress.org hosts the contributor teams building WordPress core itself, while the WordPress.org support forums cover plugin and theme-level questions.
  • TYPO3: the TYPO3 Slack, linked from typo3.org, is where the enterprise-CMS-specific questions this book only had room to introduce get real depth.
  • API Platform: GitHub Discussions on the project’s own repository, linked from api-platform.com, is where most implementation questions land.
  • Nextcloud: the community forum and GitHub discussions linked from nextcloud.com cover both self-hosting operations and app development.

A broader habit worth keeping

Beyond any single framework’s community, PHP as a whole has an active conference and local user group scene, and most major frameworks release detailed changelogs and upgrade guides rather than surprising you. Following a framework’s own release notes, not just its marketing announcements, is the single most effective habit for staying current without constantly re-learning your stack from scratch.

Under the hood: Not applicable here. Communities are made of people, not code, and that’s exactly the point: the fastest answer to a stuck problem is often someone who hit the same wall last month, not a deeper read of the source.

If You Get Curious About What’s Underneath

If you opened every “Under the hood” box in this book, you already know more about the engine than this book ever asked you to learn. You’ve seen PHP’s Fibers make a real-time WebSocket server possible, attributes and reflection generate an entire API from one class, and a type system solid enough for static analysis tools to catch bugs before a single test runs. None of that was framed as a language lesson. It was framed as “here’s why the convenience you just used actually works.”

If that pattern started to interest you for its own sake, not because a feature needed it, that’s the moment this book was quietly built to lead to.

The PHP Book picks up exactly there: variables, types, control flow, classes, and the engine’s own error model, taught properly rather than reverse-engineered from a framework’s behavior. It assumes the same thing you brought here, real coding experience elsewhere, but points it at the language itself instead of at a specific framework’s conventions.

You don’t need it to keep shipping with everything in this book. Every feature covered here works today, in production, without opening that book once. It’s simply there for whenever “just ship it” turns into “wait, how does this actually work,” which, if you’ve made it this far, might be sooner than you expected.

Read The PHP Book →

Appendix

Three reference pages, meant for looking things up rather than reading straight through:

A - The Stack Cheat Sheet

One page, use case on the left, recommended pick on the right. A $ marks anything that isn’t open source. Where two picks appear, the first is usually the faster start.

You need to ship…Reach forChapter
A custom application with real business logicLaravel or SymfonyCh. 1
A marketing site or blog a client edits themselvesWordPressCh. 3
A large, multi-department, multilingual institutional siteTYPO3Ch. 3 / Ch. 13
A one-off script or CLI tool, no frameworkSymfony/Console, Guzzle, MonologCh. 2
Login, sign-up, password resetLaravel Breeze/Fortify, Symfony Security, or WordPress rolesCh. 4
An internal admin panelLaravel Filament, Symfony EasyAdmin, or Nova $Ch. 5
A REST/GraphQL API with docsAPI PlatformCh. 6
Live updates without a page refreshLaravel Reverb, Symfony Mercure, or Pusher $Ch. 7
File sharing and sync (“our own Dropbox”)NextcloudCh. 8
Ordinary file uploads in an existing appLaravel Storage / League/FlysystemCh. 8
A product catalog and checkoutWooCommerce, Sylius, or Cashier + Stripe $Ch. 9
Search that tolerates typos and ranks by relevanceLaravel Scout + Meilisearch (or Algolia $)Ch. 10
Background jobs and scheduled tasksLaravel Queues, Symfony Messenger, or Action SchedulerCh. 11
An AI-powered feature added to an existing appLaravel Prism or Symfony AI BundleCh. 12
Multiple languages or multiple regional sitesTYPO3, WPML, or Symfony TranslationCh. 13
Tests, static analysis, and vulnerability scanningPest/Larastan, PHPStan/Psalm, composer auditCh. 14
Production error visibilitySentry (or Flare $)Ch. 14
More throughput without rewriting the appFrankenPHP / Laravel OctaneCh. 15
Finding an actual performance bottleneckBlackfire $Ch. 15
Deploying a Laravel appForge or Vapor $Ch. 16
Deploying onto an existing AWS/GCP/Azure contractApp Runner, Cloud Run, or App ServiceCh. 16
Deploying across several different frameworks consistentlyPlatform.sh $Ch. 16
Deploying WordPress at real, revenue-generating scaleKinsta or WP Engine $Ch. 16
Self-hosting Nextcloud in productionThe All-in-One Docker imageCh. 16

B - Glossary of Ecosystem Terms

Defined by what each one does for you, not by its formal definition.

Adapter A small piece of code that makes one thing (a storage backend, a search engine) speak the interface your application expects, so swapping the underlying service doesn’t mean rewriting the application. See League/Flysystem.

Attribute A piece of metadata written directly above a PHP class or method (#[ApiResource], #[IsGranted('ROLE_ADMIN')]) that a framework reads at runtime to decide how to treat it, replacing what used to require separate configuration files.

Bundle Symfony’s word for an installable package that adds a feature to an application: the Security Bundle, the AI Bundle. Roughly equivalent to a Laravel package or a WordPress plugin.

Facade A Laravel convention: a simple, static-looking call (Storage::get(...)) that’s actually backed by a real, swappable object underneath, giving you a short, memorable syntax without losing the flexibility of dependency injection.

Fiber A PHP 8.1 feature that lets a single process pause and resume execution at will, holding many things “in flight” at once without needing a thread per task. What makes Reverb’s WebSocket server possible.

Hook / Filter WordPress’s extension mechanism: a named point in core code where a plugin can run its own function (add_action), or modify a value as it passes through (add_filter), without editing WordPress core itself.

Migration A version-controlled, incremental change to a database schema, written in code rather than applied by hand, so every developer and every environment ends up with the same database structure by running the same migration files.

ORM (Object-Relational Mapper) The layer that turns database rows into PHP objects and back (Eloquent in Laravel, Doctrine in Symfony), so most day-to-day code works with $product->price instead of hand-written SQL.

Provider (Service Provider / Service Container) The part of a framework responsible for constructing objects and handing them to whatever needs them, so a class can simply declare “I need a logger” in its constructor without knowing how that logger gets built or configured.

PSR A PHP Standards Recommendation: an agreed-upon interface (PSR-3 for logging, PSR-7 for HTTP messages) that lets independently built packages, from different vendors, work together without one needing to know the other’s internals.

Resource In an API context, a single type of thing your API exposes (a Product, an Order), along with the operations available on it. Also used more narrowly in Laravel for the class that shapes a model into JSON.

Webhook A callback: instead of your app repeatedly asking a service “did anything happen yet,” the service sends your app an HTTP request the moment something does. How Stripe tells your app a payment succeeded.

Worker A long-running process that pulls tasks off a queue and executes them, separate from the process handling web requests, so slow work doesn’t block a user waiting for a page to load. See Shipping Background Work.

C - Index of “Under the Hood” Boxes

Every optional aside in this book, in reading order, in case one of them stuck with you and you want to go find it again.

ChapterWhat it covers
Framework, CMS, or Headless?Why frameworks, CMSes, and API tools share the same underlying language features underneath different packaging
Scaffolding a First ProjectComposer’s dependency resolution and autoloading
Symfony/ConsoleConsole commands as plain PHP classes, no special runtime
GuzzlePSR-7 and PSR-18, the interfaces that let HTTP clients interoperate
League/PlatesWhy plain-PHP templates need no compile step
MonologPSR-3, the standard logging interface
Respect/ValidationValidation libraries leaning on PHP’s own type system
League/FlysystemInterface-based polymorphism applied to storage
WordPress Block ThemesBlocks stored as structured HTML, not PHP templates
TYPO3 Structured ContentTCA as metadata-driven UI generation
StatamicFlat files, PHP’s filesystem functions, and caching instead of SQL
Craft CMSDynamic property access powering flexible content fields
Laravel Breeze/FortifyWhy password hashing is deliberately slow (bcrypt/argon2id)
Symfony SecurityThe #[IsGranted] attribute and reflection
WordPress RolesCapabilities as plain strings, not a formal permissions engine
Laravel FilamentFluent, chainable objects enabled by named arguments and enums
Symfony EasyAdmin/SonataReading Doctrine entity metadata to infer field types
API Platform AdminGenerating an OpenAPI/Hydra schema from attributes
WordPress CPT as CRUDEvery post type sharing one wp_posts table
Laravel NovaWhy a paid license changes nothing about the underlying package mechanism
API Platform from One ClassAttributes plus reflection generating routing, validation, and docs
Laravel SanctumWhy tokens are hashed differently than passwords
WordPress REST APIThe REST API built on WordPress’s hooks and filters
Laravel Reverb/LivewirePHP Fibers, and how they make a WebSocket server possible
Symfony Mercure/TurboServer-Sent Events as a plain HTTP feature
Nextcloud TalkNextcloud’s app system and PHP’s own autoloading conventions
PusherWhy a good abstraction makes “self-host or pay” a reversible decision
Nextcloud StorageChunked uploads and ETags for efficient sync
Laravel Storage/S3Pre-signed URLs and cryptographic signatures
WordPress Media LibraryEager thumbnail generation via GD/Imagick
WooCommerceCommerce data modeled on the same wp_posts/wp_postmeta tables
SyliusBuilt from reusable Symfony components, not invented from scratch
Laravel Cashier/StripeVerifying webhook signatures before trusting a payload
Laravel ScoutA driver interface making search engines swappable
TYPO3 SolrMirroring page permissions into the search index
WordPress SearchWP_Query as the hook point for smarter search backends
Laravel QueuesJob serialization and why jobs should stay simple
Symfony MessengerRouting messages to handlers via PHP’s type system
WordPress WP-CronWhy WP-Cron triggers on page loads instead of a system timer
Laravel PrismA provider interface, the same pattern as Flysystem’s adapters
Symfony AI BundleDependency injection applied to an AI agent
WordPress AI PluginsWhy WordPress ships its own portable HTTP client
TYPO3 Multilingual TreesTranslations as linked records, not extra columns
WordPress Multisite/WPMLTwo different structural solutions to two different problems
Symfony TranslationTranslation files compiled into cached PHP arrays
Laravel Pest/LarastanStatic analysis reasoning over type hints without running code
Symfony PHPStan/RectorRector rewriting code by parsing and modifying its AST
WordPress TestingTest isolation via database transaction rollback
Cross-Ecosystem SASTData-flow analysis versus type-checking
Sentry/FlareHooking into PHP’s own exception and error handlers
FrankenPHP/OctaneWorker mode as the mirror image of Fibers
TYPO3 CachingTag-based cache invalidation
Nextcloud ScalingStorage adapters again, this time under scaling pressure
BlackfireProfiling via a Zend Engine extension
Laravel Forge/VaporStatelessness as the enabler of serverless deployment
Cloud HostingThe same PHP process, regardless of which cloud runs it
Platform.shInfrastructure as versioned configuration
WordPress Managed HostingObject caching with Redis/Memcached
Nextcloud AIOContainer orchestration via the mastercontainer pattern