For years, running PHPStan at level 8 on a CakePHP app meant making peace with a wall of missingType.generics and missingType.iterableValue warnings, or quietly silencing them in ignoreErrors. The ORM knew the entity type. The query knew its result type. PHPStan just could not see any of it.
That era is over. As of CakePHP 5.3.6+ and cakephp-ide-helper 2.19.3+, a CakePHP app is officially generics-able: you can run level 8 with generics and land on a clean 0 errors – no blanket ignores.
Two things had to line up: the framework had to declare the generics, and the tooling had to emit the matching doc-blocks.
CakePHP 5.3 is where it clicked into place. The relevant template declarations:
Cake\ORM\Table carries @template TEntity (since 5.3.4), on top of the long-standing behavior template.
find() / findOrCreate() / loadInto() family flows TEntity through (a slightly later change).
Cake\View\Helper is generic over its view: @template TView of \Cake\View\View.
Cake\Event\EventInterface, Cake\ORM\Query\SelectQuery, Cake\Datasource\ResultSetInterface and friends are all parameterized.
So the information was finally there. The base classes could say “a UsersTable only ever deals with User entities” in a way PHPStan understands.
Declaring templates upstream is only half the story. Your own src/ classes still need the matching annotations, and those are generated by dereuromark/cakephp-ide-helper.
It generates fully parametrized annotations straight into your source – @method Cake\ORM\Query\SelectQuery<\App\Model\Entity\User> find(...), @property \Cake\ORM\Association\HasMany<\App\Model\Table\CommentsTable> $Comments, typed entity setters – and then you are done. PHPStan, your IDE, and every other tool just read plain committed doc-blocks. Nothing has to boot or stay resident to understand your models.
With the generated approach, the generics are in the file, version-controlled, reviewable, and tool-agnostic.
The annotator already had a tri-state switch for this – it just was not turned on, and a few generated shapes still leaked a bare array:
IdeHelper.genericsInParam is the switch. Set it to true for basic generics, or 'detailed' for fully detailed shapes (array<string, mixed>, ResultSetInterface<int, TEntity>, …).
// config/app.php (or app_local.php)
'IdeHelper' => [
'genericsInParam' => true,
'concreteEntitiesInParam' => 'strict',
],
Then re-run the annotator and your table doc-blocks turn from this:
/**
* @method \App\Model\Entity\User saveOrFail(\App\Model\Entity\User $entity, array $options = [])
*/
into this:
/**
* @method \App\Model\Entity\User saveOrFail(\App\Model\Entity\User $entity, array<string, mixed> $options = [])
*/
On top of that the new propertyTypeMap config also can help you with a larger code base:
IdeHelper:2.21.0
Make sure to check out this release update.
Flipping the switch got us most of the way. One small leak remained, fixed upstream:
extends tag, so every helper tripped missingType.generics on TView. It now prepends @extends \Cake\View\Helper<\Cake\View\View> – gated by a runtime reflection check on the parent, so it self-disables on older cores instead of emitting an invalid generic.
When you override a framework method, you cannot narrow a parameter type below the parent’s. A bare array in the parent is effectively array<array-key, mixed>, so an override typed array<string, mixed> raises method.childParameterType:
Parameter #1 … should be contravariant with parameter … of method …::beforeFilter()
The fix is to use the equal type, not a narrower one:
/**
* @param \Cake\Event\EventInterface<\Cake\Controller\Controller> $event
*/
public function beforeFilter(EventInterface $event) { /* ... */ }
// and for plain array overrides, prefer the wide form:
// @param array<mixed> $config (not array<string, mixed>)
array<mixed> still satisfies missingType.iterableValue while staying contravariant with the parent. Best of both.
My sandbox app went from 383 errors to 0 at PHPStan level 8 with phpstan-strict-rules and type-perfect enabled – and crucially, with the two blanket ignores removed:
ignoreErrors:
- - identifier: missingType.generics
- - identifier: missingType.iterableValue
- identifier: new.internalClass
CakePHP now joins Symfony/Doctrine in clean level-8-with-generics territory, and goes further by auto-generating the annotations via IdeHelper.
And we are light-years ahead of other major PHP frameworks that are still on PHPStan level 1-5.
CakePHP ships with a clean architecture, no hidden magic or anti-patterns and enables developers the right way.
PHPStan level 8+ on core and app level ensures the highest possible code quality and developer experience and literally prevents bugs and security issues before they happen.
Flip the switch, re-annotate, delete the ignores and enjoy one of the leading rapid application development frameworks in the ecosystem.
Every CakePHP project I’ve ever opened has the same file. It’s called something like MenuHelper.php or Navigation.php, it lives somewhere in src/View/Helper/, and it grows a new if ($this->request->getParam('controller') === ...) branch every quarter. By the time anyone notices, the helper is doing three jobs at once — declaring the tree, deciding which entry is active, and emitting the markup — and changing any one of them risks breaking the other two.
Over time I also often used the Tools.Tree helper to build tree structures from plain PHP arrays or from database query results.
cakephp-menu is the plugin we all might need. It builds nested menus from plain PHP (or from arrays, or from a database), resolves active state and visibility from the request, and renders to a string template, Bootstrap 5, a full navbar, a collapsible sidebar, breadcrumbs, or JSON — and it keeps those three concerns separate so each one is testable on its own.
This post is a tour of how the plugin is structured, the design choices behind that structure, and a recent round of “make the tree honest” hardening that landed before the latest release.
Want to poke at every renderer and option without cloning anything? The plugin has a hosted playground at https://sandbox.dereuromark.de/menu-sandbox — tweak menu definitions, flip resolvers, swap renderers, and watch the markup update.
The mental model is three steps, in order:
flowchart LR
A["1 · Build<br/>tree"] --> B["2 · Resolve<br/>per-request"] --> C["3 · Render<br/>markup"]
Build declares what is in the menu — labels, links, icons, badges, nested submenus. It knows nothing about the current request or the logged-in user. Resolve takes the built tree and applies per-request state on top: which item is active, which branch is an ancestor of the active item, which items are visible to this user. Render turns the resolved tree into output.
The split matters because it’s what makes the same menu definition reusable. A menu you define once at boot time is resolved fresh for every request and rendered into whatever shape that page needs — a sidebar here, a breadcrumb there, JSON for the SPA tab on the same page.
The common case is fluent PHP:
use Menu\Menu;
$menu = Menu::create(['class' => 'nav nav-pills']);
$menu->addItem('Home', '/');
$menu->addItem('Docs', 'https://book.cakephp.org', [
'attributes' => ['target' => '_blank', 'rel' => 'noopener'],
]);
$account = $menu->addItem('Account', '#');
$account->getSubMenu()->addItem('Profile', ['controller' => 'Users', 'action' => 'profile']);
$account->getSubMenu()->addItem('Logout', ['controller' => 'Users', 'action' => 'logout']);
A link can be a string URL or a CakePHP array URL. The array form is almost always the better choice: the Router resolves base paths, plugins, and prefixes for you, which means active matching keeps working in subdirectory installs and across plugin remounts. The string form is there for external links and the odd hard-coded edge case.
A few things you get out of the box, with no extra helpers:
addHeader) render as a non-link <li> — perfect for grouping a sidebar.
addDivider) render the <hr> / separator semantic without you handling escape rules.
label.
For larger setups you can skip the PHP entirely:
$menu = Menu::fromArray([
'attributes' => ['class' => 'nav'],
'items' => [
['label' => 'Articles', 'link' => '/articles', 'submenu' => [
'items' => [['label' => 'View', 'link' => '/articles/view']],
]],
],
]);
// rows: [['id' => 1, 'parent_id' => null, 'label' => 'Articles', 'link' => '/articles'], ...]
$menu = Menu::fromFlat($rows);
fromFlat() is the one I get the most mileage out of in CMS-style apps — a single menu_items table with parent_id self-references, edited in an admin UI, materialised back into a real tree on each render. The inverse — Menu::toArray() — round-trips back to the same shape that fromArray() accepts, so a built menu can be cached, serialised, or shipped over the wire.
Building a menu is rarely a one-shot operation. The most common reason you reach for this plugin in the first place is “plugin X wants to add an item to menu Y, after the third entry, but only if the user is staff”. So the API for re-arranging items after they exist is first-class:
// Insert relative to a sibling (id or key):
$menu->insertBefore($menu->newItem('What\'s New', '/changelog'), 'articles');
$menu->insertAfter($menu->newItem('Beta', '/beta'), 'home');
// Move an existing item to a specific slot:
$menu->moveToFirstPosition('account');
$menu->moveToLastPosition('logout');
$menu->moveToPosition('articles', 2);
// Reorder by id/key (unlisted items keep their order, appended after):
$menu->reorder(['home', 'articles', 'account']);
// Sort, filter, and find across direct children:
$menu->sortBy('weight');
$menu->filter(fn ($item) => $item->isVisible());
$items = $menu->find(fn ($item) => str_starts_with($item->getLabel() ?? '', 'Admin'));
// Active state lookups:
$current = $menu->getActiveItem();
$menu->clearActive();
Two operations that show up in surprising places:
merge($other) deep-copies another menu’s items into this one. The source stays intact, so you can keep a master menu and merge slices into per-page composites without aliasing bugs.
slice($offset, $length) and split($at) give you derived menus by copy. “Render this menu as a two-column dropdown” becomes:
['primary' => $left, 'secondary' => $right] = $menu->split(4);
Pair these with the tree-integrity guarantees and you can rearrange aggressively without the “why is this item rendering twice” class of bug ever showing up.
The helper lets you register menus by name once and render them many times:
$this->Menu->register('main', static function ($menu): void {
$menu->addItem('Home', '/');
$menu->addItem('Articles', ['controller' => 'Articles', 'action' => 'index']);
});
echo $this->Menu->render('main');
register() is idempotent by default — calling it twice returns the same menu — so you can scatter the call across templates and elements without worrying about double-registration. Pass ['rebuild' => true] if you actually want to wipe and rebuild.
Beyond register / create / render, the helper carries the breadcrumb integration and the named-menu lifecycle:
// Named-menu lifecycle:
$main = $this->Menu->getOrCreate('main');
$this->Menu->has('main');
$this->Menu->remove('main');
$this->Menu->reset(); // wipe every named menu
// Active item + path (after resolution):
$current = $this->Menu->getCurrentItem('main');
$path = $current ? $this->Menu->extractPath($current) : [];
// Breadcrumbs — three flavours depending on where you want to render:
$crumbs = $this->Menu->getBreadcrumbs('main'); // plain array
$this->Menu->populateBreadcrumbs('main'); // push into CakePHP's BreadcrumbsHelper
echo $this->Menu->renderBreadcrumbs('main'); // render directly via BreadcrumbRenderer
The three breadcrumb flavours are deliberate. getBreadcrumbs() is for code that wants to do something with the path itself (a JSON-LD BreadcrumbList, an SEO meta tag, a custom layout). populateBreadcrumbs() plays nicely with CakePHP’s stock BreadcrumbsHelper so existing layout code keeps working. renderBreadcrumbs() is the “just give me the markup” shortcut.
The resolver layer is where I think the plugin earns its keep. Instead of every menu helper sprouting a giant match over controllers and actions, you compose a small ResolverCollection. The bundled resolvers cover most apps out of the box:
| Resolver | What it sets | Reads from |
|---|---|---|
UrlArrayResolver |
active | item link array vs request route |
Psr7UrlResolver |
active | item link string vs request URI |
SectionResolver |
active | item data.section vs request params |
RegexResolver |
active | item data.regex vs request URL |
LoggedInResolver |
visible | identity present/absent |
PermissionResolver |
visible | item data.permission callback |
AuthorizationResolver |
visible | closure per item (TinyAuth-shaped) |
CallbackResolver |
active / visible | arbitrary closure per item |
Compose them in any order:
use Menu\Resolver\Psr7UrlResolver;
use Menu\Resolver\UrlArrayResolver;
use Menu\Resolver\SectionResolver;
use Menu\Resolver\LoggedInResolver;
use Menu\Resolver\AuthorizationResolver;
$resolvers = (new \Menu\Resolver\ResolverCollection())
->add(new UrlArrayResolver($request)) // active state
->add(new SectionResolver($request)) // also active state (section badges)
->add(new LoggedInResolver($identity)) // visibility
->add(new AuthorizationResolver(fn ($item) =>
is_array($item->getLink()?->getRawUrl())
? $this->AuthUser->hasAccess($item->getLink()->getRawUrl())
: null,
));
Each resolver inspects every item, mutates runtime state (active, visible, expanded), and hands the tree to the next one. Order matters — later resolvers see what earlier ones decided — but the structure of the tree never changes. Re-render the same menu for a different request and the previous active state is overwritten cleanly.
A couple of well-loved options that ride on top:
singleActive => true — when several items match the current URL (a parent and a child both pointing at the same route, for example), keep only the deepest visible match active. Breaks ties by document order so the active trail is unambiguous.
hideEmptyBranches => true — if an authorization resolver hides every leaf under a parent, skip the now-empty dropdown instead of rendering a hollow chevron.
additionalResolvers — the helper applies the URL resolvers automatically; this option lets you tack extras on without losing the defaults.
The TinyAuth recipe is the one that gets people most excited:
use Menu\Item\ItemInterface;
use Menu\Resolver\AuthorizationResolver;
echo $this->Menu->render('admin', [
'hideEmptyBranches' => true,
'additionalResolvers' => [
new AuthorizationResolver(function (ItemInterface $item): ?bool {
$url = $item->getLink()?->getRawUrl();
return is_array($url) ? $this->AuthUser->hasAccess($url) : null;
}),
],
]);
A single menu definition, no role-aware branches in the template, and every user sees exactly the items they can actually reach.
The default UrlArrayResolver is more forgiving than it looks. A few item options let you fine-tune what counts as active without writing a custom resolver:
// Named CakePHP routes — works against Router::url(['_name' => ...]):
$menu->addItem('View', ['_name' => 'articles:view']);
// Fuzzy / prefix matching — /articles/view/42 still marks /articles/view active:
$menu->addItem('Articles', ['controller' => 'Articles', 'action' => 'view'], [
'fuzzy' => true,
]);
// Alternate routes — mark this item active for any of these too:
$menu->addItem('Inbox', ['controller' => 'Messages', 'action' => 'index'], [
'matchRoutes' => [
['controller' => 'Messages', 'action' => 'unread'],
['controller' => 'Messages', 'action' => 'starred'],
],
]);
// Per-item override of query-string handling:
$menu->addItem('Search', '/search', ['ignoreQueryString' => true]);
And two helper-level knobs control how far matching runs:
resolveDepth — caps how deep into the tree the default URL resolvers scan. Useful when you have a giant CMS-backed menu and you only care about the first two levels for active state.
depth (renderer option) — caps how deep the renderer goes, independently. hideEmptyBranches always looks at visibility, not the depth cutoff, so a top-level item with hidden children is kept when its submenu is truncated by depth but dropped when authorization hid everything under it.
The combination is the “a hundred-item plugin menu, two visible levels, active state still correct” setup that almost every admin app eventually needs.
A renderer never decides active state or visibility. It only reflects what resolution already decided. That means swapping renderers is a per-call choice, not a refactor:
echo $this->Menu->render($menu); // string template
echo $this->Menu->render($menu, ['renderer' => Bootstrap5Renderer::class]);
echo $this->Menu->render($menu, ['renderer' => NavbarRenderer::class]);
echo $this->Menu->render('sidebar', ['renderer' => Bootstrap5SidebarRenderer::class]);
echo $this->Menu->render($menu, ['renderer' => JsonRenderer::class, 'pretty' => true]);
echo $this->Menu->renderBreadcrumbs('main');
The bundled renderers all emit accessible markup out of the box — aria-current="page" on the active label, aria-expanded on collapsible branches, the right role attributes on nav / menubar / menuitem. The sidebar wires each branch to its collapse element through a unique id so it works with the stock Bootstrap bundle and zero custom JavaScript.
The distinction between Bootstrap5Renderer and NavbarRenderer is worth calling out: Bootstrap5Renderer emits the inner <ul class="nav"> and expects you to wrap it. NavbarRenderer emits the whole <nav> chrome — brand, responsive navbar-toggler, the collapsible navbar-nav, and dropdowns for nested items. Drop it straight into a layout and you have a working top bar; no extra markup, no Bootstrap JS glue.
If none of them fit, the renderer interface is small enough that a custom one is an afternoon’s work — and per-call template overrides let you tweak just the bits you care about without subclassing.
The pipeline is only as trustworthy as the data structure under it. A menu is a tree of items, and trees are easy to corrupt by accident — the same item attached to two parents, a removed item that still thinks it has a parent, a child added back to its own grandchild’s submenu. The plugin used to let you do all of these. It didn’t lie, exactly, but the failure modes were quiet: the wrong branch rendered, the wrong item resolved as active, a stale reference held the old parent’s classes.
A recent round of PRs tightened this up.
Direct menu ownership. Items now know which Menu they were last attached to. Move an item across menus and the source forgets it; the destination claims it. That single invariant rules out the “item belongs to A but lives in B’s tree” class of bug.
Detach on move. add() / insertBefore() / insertAfter() now detach the item from its previous parent before reattaching. Before this, moving an item into a different submenu left it attached to the old one as a phantom — most renderers happily rendered it twice.
Explicit detach(). If you hold a reference to an attached item and want to move or reuse it elsewhere, you say so:
$item->detach();
$otherMenu->add($item);
The fluent setter is part of ItemInterface, so editor tooling surfaces it the moment you start typing.
Cycle checks. Adding an item into its own descendant subtree now throws instead of producing a RuntimeException deep in the renderer (where the stack trace is nine frames away from your actual mistake).
Fail loudly on misses. remove() and removeByKey() now throw when the id or key doesn’t exist, instead of silently returning. The old swallow-and-continue behaviour was a footgun every time you renamed an item: the removal silently did nothing and the menu kept showing the old entry.
Most of these are behaviour changes, not API changes — code that was already correct still works. Code that relied on silent no-ops will surface real exceptions. That’s the point.
Menu::collect() returns an ItemCollection over the whole tree depth-first, so you can findById, findByKey, or findByParent without recursing by hand.
Menu::merge(), Menu::slice(), Menu::split() give you derived menus by copy — the source stays intact, which means “render this menu as two columns” is two method calls.
freeze() locks the structure of a menu but leaves runtime resolver state mutable. Useful when you want to publish a built menu from a service and have application code resolve it without accidentally adding items.
bin/cake menu generate Main writes a config/menu_main.php spec under the Menu.menus Configure key. The helper auto-registers anything it finds there on initialize(), so $this->Menu->render('main') works without any wiring in AppView.
Bootstrap5Renderer honours per-item displayChildren = false on the anchor itself, not just the submenu, so admin chrome stays clean when you only want the parent clickable.
The plugin has no runtime dependencies beyond CakePHP itself, ships on PHPStan level 8, and the entire test suite runs in under a second. That’s deliberate — a menu plugin should never be the thing that pushes you onto a higher Cake or PHP minimum, and it should never be the thing that slows your test suite down. The trade is that everything beyond the bundled renderers and resolvers is yours to write, but the interfaces are small enough that you’ll be writing fifty-line classes, not three-hundred-line subclasses.
composer require dereuromark/cakephp-menu
bin/cake plugin load Menu
Full docs, including the recipes catalogue, live at https://dereuromark.github.io/cakephp-menu/. The live sandbox is the fastest way to see the renderers side-by-side before you wire it into your app.
If you maintain a CakePHP app and you’ve got a hand-rolled MenuHelper lying around, this is the plugin you can replace it with — and once the build / resolve / render split clicks, you’ll wonder why you kept reinventing it.
… and how we automated it in CakePHP
Most security incidents do not start with a genius attacker. They start with an honest person who found something and could not figure out how to tell you. If reporting a bug is harder than tweeting about it, you have quietly chosen public disclosure for them.
At the same time AI makes it possible to even automate security testing on a scale we have not seen before. Every single day now there is a vulnerability found (and reported) somewhere out there.
security.txt fixes the boring part of that problem: it tells finders exactly
where to send a report. This post covers what the standard is, why lowering the
reporting barrier matters, and how we turned it into a one-liner for CakePHP apps
with an always-fresh middleware.
Put yourself in a researcher’s shoes. You notice an exposed endpoint on a site. You want to do the right thing and report it privately. So you start hunting:
/security page? Usually not.
security@ mailbox? Maybe, maybe monitored.
Every dead end raises the odds of one of two bad outcomes: the reporter gives up, or the details end up somewhere public. Neither is what you want.
security.txt is a small, plain-text file described by
RFC 9116, “A File Format to Aid in
Security Vulnerability Disclosure” (Informational, 2022, by Edwin Foudil and
Yakov Shafranovich). You serve it over HTTPS as text/plain at a well-known
location:
https://example.com/.well-known/security.txt
Inside, it is just Field: value lines. Two fields are required, the rest are
optional:
| Field | Required | Purpose |
|---|---|---|
Contact |
Yes | How to reach you: an https: URL, mailto:, or tel:. May repeat, in order of preference. |
Expires |
Yes | When the file’s data should no longer be trusted (ISO 8601). |
Encryption |
No | Where to find your public key, so reports can be encrypted. |
Policy |
No | Link to your disclosure policy. |
Acknowledgments |
No | Your hall of fame for past reporters. |
Preferred-Languages |
No | Languages you can read, e.g. en, de. |
Canonical |
No | The canonical URL of this file. |
Hiring |
No | Security-related job openings. |
A couple of details matter in practice:
Contact lines are read top-down as order of preference.
Expires is the trap. It must be a date in the future. A security.txt you
wrote two years ago and forgot is, by the spec, stale — and a stale file
signals an inattentive team.
Why a standard at all? Because researchers should not have to reverse-engineer your org chart. One predictable path, machine-readable, that scanners and humans both know to check.
security.txt is cheap to add and pays off in two ways.
It reduces friction. The faster a finder can reach the right inbox, the more likely they report at all — and the less likely the issue leaks while they search. You are removing excuses for public disclosure.
It routes people to the proper channel. This is the part teams underrate. “Proper channel” means private, monitored, and expected:
Policy so the reporter knows what to expect: do you offer safe
harbor? A disclosure timeline? Credit?
Make that path obvious and you turn a potential zero-day-on-Twitter into a quiet, coordinated fix. That is the whole game: make auditing and reporting easy, and make the easy path the safe one.
We wanted security.txt on our CakePHP apps, but a static file has that
Expires problem — somebody has to remember to bump the date forever. So we built
it as a small PSR-15 middleware in the
cakephp-setup plugin
(3.21.0+).
See it in action: sandbox.dereuromark.de/.well-known/security.txt
A few design choices made it pleasant to use:
Expires is computed on every request. By default it is “one year from now”,
so it is always valid. The maintenance problem simply disappears.
SecurityTxt
object — named arguments, IDE autocomplete, and type checks — instead of a loose
array of magic strings.
Wiring it up is one call in your application’s middleware stack:
use Setup\Middleware\SecurityTxt;
use Setup\Middleware\SecurityTxtMiddleware;
public function middleware(MiddlewareQueue $middlewareQueue): MiddlewareQueue
{
$middlewareQueue->add(new SecurityTxtMiddleware(new SecurityTxt(
contact: 'https://github.com/owner/repo/security/advisories/new',
canonical: 'https://example.com/.well-known/security.txt',
policy: 'https://github.com/owner/repo/security/policy',
preferredLanguages: 'en, de',
)));
// ... the rest of your stack
return $middlewareQueue;
}
That serves the following at both /.well-known/security.txt and the legacy
/security.txt, with a fresh Expires on every hit:
Contact: https://github.com/owner/repo/security/advisories/new
Policy: https://github.com/owner/repo/security/policy
Canonical: https://example.com/.well-known/security.txt
Preferred-Languages: en, de
Expires: 2027-05-23T00:00:00.000Z
Other niceties: HEAD requests return headers without a body, path matching is
base-path aware (so it works for apps mounted in a subdirectory), and there is a
raw-array escape hatch for fields the value object does not cover yet.
If you have an open (Git) repository for your project, this can be an additional help.
security.txt says where to report; a SECURITY.md says how and what to
expect. On GitHub, a SECURITY.md (in the repo root, .github/, or docs/) is
rendered at /security/policy and surfaced in the repo’s Security tab — which is
exactly what the Policy field above points to.
The two reinforce each other:
Contact points at GitHub’s private vulnerability reporting, not a public
issue.
Policy points at SECURITY.md, which spells out the process and timeline.
Now both a scanner and a human land in the same private, expected place.
Adding security.txt is one of the highest-leverage, lowest-effort things you can
do for the security posture of a public app. It is a handful of lines, and it
tells the world you want to hear about problems.
Make the door easy to find, put a doorbell on it, and answer when it rings.
The PSR-15 middleware can easily be ported to any PSR-15-compatible framework. Adjust it by copy and paste into your ecosystem if needed (middleware + DTO). The Response class would need to be replaced by whatever you might be using. Also the InstanceConfigTrait usage.
Here a possibly agnostic version:
<?php
declare(strict_types=1);
/**
* Serves an RFC 9116 security.txt. Pure PSR-15 + PSR-17: it depends only on the
* injected factories, so it runs in any compliant stack.
*
* The required `Expires` field is computed on every request, so it never goes
* stale. `Contact` is required; constructing without one throws.
*/
class SecurityTxtMiddleware implements MiddlewareInterface
{
/** @var array<string, string|array<string>> */
private array $fields;
private string $expiresInterval;
public function __construct(
SecurityTxt $document,
private readonly ResponseFactoryInterface $responseFactory,
private readonly StreamFactoryInterface $streamFactory,
private readonly string $path = '/.well-known/security.txt',
private readonly bool $serveRootFallback = true,
private readonly int $cacheMaxAge = 86400, // 1 day; was CakePHP's DAY constant
) {
$this->fields = $document->toFields();
$this->expiresInterval = $document->expiresInterval;
if (SecurityTxt::normalize($this->fields['Contact'] ?? null) === []) {
throw new InvalidArgumentException(
'SecurityTxtMiddleware requires at least one non-empty Contact field (RFC 9116).',
);
}
}
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
{
$method = $request->getMethod();
if ($method !== 'GET' && $method !== 'HEAD') {
return $handler->handle($request);
}
if (!$this->matches($this->relativePath($request))) {
return $handler->handle($request);
}
$rendered = $this->render();
$response = $this->responseFactory->createResponse()
->withHeader('Content-Type', 'text/plain; charset=utf-8')
->withHeader('Content-Length', (string)strlen($rendered)); // correct even for HEAD
if ($this->cacheMaxAge > 0) {
$response = $response->withHeader('Cache-Control', 'max-age=' . $this->cacheMaxAge);
}
// HEAD: same headers as GET, but no body.
$body = $method === 'HEAD' ? '' : $rendered;
return $response->withBody($this->streamFactory->createStream($body));
}
private function matches(string $path): bool
{
return $path === $this->path
|| ($this->serveRootFallback && $path === '/security.txt');
}
/**
* Resolve the request path relative to the application base path. The `base`
* attribute is set by some frameworks (e.g. CakePHP) for subdirectory installs;
* elsewhere getAttribute() returns '' and this is a pure-PSR-7 no-op.
*/
private function relativePath(ServerRequestInterface $request): string
{
$path = $request->getUri()->getPath();
$base = (string)$request->getAttribute('base', '');
if ($base !== '' && str_starts_with($path, $base)) {
$path = substr($path, strlen($base));
}
return $path !== '' ? $path : '/';
}
private function render(): string
{
$lines = [];
foreach (SecurityTxt::normalize($this->fields['Contact'] ?? null) as $contact) {
$lines[] = 'Contact: ' . $contact;
}
foreach ($this->fields as $name => $value) {
if ($name === 'Contact' || $name === 'Expires') {
continue;
}
foreach (SecurityTxt::normalize($value) as $item) {
$lines[] = $name . ': ' . $item;
}
}
$lines[] = 'Expires: ' . $this->expires();
return implode("\n", $lines) . "\n";
}
private function expires(): string
{
$timestamp = strtotime($this->expiresInterval) ?: strtotime('+1 year');
return gmdate('Y-m-d\TH:i:s.000\Z', (int)$timestamp);
}
}
A clean v2 API is coming
dereuromark/cakephp-fixture-factories just shipped 2.0.0 RC. After a year of incremental cleanup — typed persistEntity() / persistEntities(), the TEntity template on BaseFactory, redirected deprecations — the next major was the right moment to redesign the surface coherently rather than keep layering.
This post walks through the why, the what, and the how to upgrade.
new(), from(), count(), build(), buildMany(), save(), saveMany(), state(), sequence(), sequenceField(), for(), has(), with(), recycle(), query(), table().
@extends BaseFactory<\App\Model\Entity\Article> once; build(), buildMany(), save(), saveMany() all resolve to the concrete entity type from there.
fakerphp/faker or johnykvsky/dummygenerator, the factory picks whichever is available with no config required, and you can plug your own adapter.
recycle($entity) reuses an already-built parent across multiple belongsTo branches of an association tree, instead of silently building duplicate parents N times.
TableAssertionsTrait adds direct database-state assertions (assertTableHas, assertTableCount, assertEntityExists, …) with failure messages tuned for factory-driven tests.
The original design from vierge-noire/cakephp-fixture-factories is what made fast, factory-driven testing the default in CakePHP land in the first place — credit where it’s due. But the codebase was shaped by a PHP 7 / early-PHP-8 sensibility, and a few things had drifted out of step with where the surrounding ecosystem landed:
BackedEnum cases as first-class values both belong in this bucket.
@extends BaseFactory<\App\Model\Entity\Article> line carrying through to every terminal — build(), buildMany(), save(), saveMany(), from() — is a meaningful upgrade over the per-method docblock dance that 1.x had to do.
count() modifier separated from the entry call, directional for() / has(), named state methods over inline state(...) shapes, sequence/cycle helpers. Some of that fits Cake idiomatically, some had to be adapted (we use save over Laravel’s create because Table::save() is the native verb), but the shape benefits from the cross-pollination.
make() / makeMany() / getEntity() / getEntities() / persist() / persistEntity() / persistEntities() plus static finders on the factory class — each addition made sense in its moment, but together they were seven-plus terminals you had to keep straight, plus a result-set return path that looked like a query result but wasn’t quite, plus mutable factories that could leak state across reuse. v1.4 cleaned up the typing without changing the shape; v2 is the right moment to redraw the shape itself.
The brief I gave myself going in: a small, internally consistent surface that one paragraph teaches end to end, no footguns the docs have to warn around, and crisp generics so the IDE just knows. Everything below follows from that.
// Singular, in-memory
$user = UserFactory::new()->build();
// Singular, persisted
$user = UserFactory::new()->save();
// Plural via count() — note the explicit *Many() terminal
$users = UserFactory::new()->count(5)->saveMany();
// Plural with overrides applied to all
$users = UserFactory::new(['admin' => true])->count(3)->saveMany();
build / save was chosen over Laravel’s make / create because save resonates with Table::save() in the CakePHP idiom and create collides with several Cake-side meanings. saveMany() mirrors Table::saveMany() exactly.
// Inline — the ad-hoc override
$user = UserFactory::new()->state(['name' => 'Foo'])->save();
// Per-row variation across count()
$users = UserFactory::new()
->count(3)
->sequence(
['role' => 'admin'],
['role' => 'editor'],
['role' => 'user'],
)
->saveMany();
// Single-column variation that stacks across calls
$articles = ArticleFactory::new()
->count(6)
->sequenceField('status', 'draft', 'published') // 2-cycle
->sequenceField('priority', 1, 5, 10) // 3-cycle
->buildMany();
sequenceField() is the new addition. It cycles a single column independently of sequence(), and stacks across different fields with their own cardinalities — the example above produces an LCM-of-6 pattern across status × priority without you having to spell out all six combinations. It also accepts BackedEnum cases natively:
->sequenceField('status', ...Status::cases())
$user = UserFactory::new()
->afterBuild(fn (User $user) => $user->name = 'Built name')
->afterSave(fn (User $user) => $user->synced = true)
->save();
Both fire for nested factories too — when a child factory is persisted as part of its parent’s cascading save, its own afterSave() callbacks run on the saved children. (That was a quiet 1.x gap.)
for(), has(), with()The split that took the most discussion in #40:
for() — belongsTo. Auto-resolves the association from the target factory’s table; takes an optional 2nd-arg alias to disambiguate multi-association schemas.
has() — hasOne / hasMany / belongsToMany. Same auto-resolve, with an optional 2nd-arg alias and an optional pivot: named arg for habtm join-row data.
with('Alias', $factory) — explicit-alias escape hatch (still works, just verbose).
// belongsTo
$article = ArticleFactory::new()
->for(AuthorFactory::new(['name' => 'Mark']))
->save();
// has-many
$author = AuthorFactory::new()
->has(ArticleFactory::new()->count(3))
->save();
When the parent table has more than one association pointing at the target — say, Messages with both Sender and Recipient belonging to Users — for() and has() throw, not silently pick. The exception itself is paste-ready:
MessageFactory::for(UserFactory::new()) cannot resolve a unique belongsTo —
`Messages` declares 2 associations targeting `Users`:
- Sender (foreign key: sender_id)
- Recipient (foreign key: recipient_id)
Use the explicit form to disambiguate:
MessageFactory::new()->with('Sender', UserFactory::new())
MessageFactory::new()->with('Recipient', UserFactory::new())
for() and has() also accept the alias inline as a second argument if you’d rather not switch helpers:
$message = MessageFactory::new()
->for(UserFactory::new(['name' => 'Mark']), 'Sender')
->for(UserFactory::new(['name' => 'Lou']), 'Recipient')
->save();
// has() with an alias on a habtm association
$post = PostFactory::new()
->has(TagFactory::new()->count(3), 'PrimaryTags', pivot: ['featured' => true])
->save();
For repeated use, bin/cake bake fixture_factory --methods generates forSender() / forRecipient() wrapper methods on the factory itself, co-locating the alias with the schema knowledge in one place.
recycle()When the same parent shows up on multiple belongsTo branches of a build graph — say, a Country referenced both by User directly and by each Address the user has — 1.x silently built a fresh Country per branch and you’d find out only when a code uniqueness assertion blew up downstream. recycle() hands the factory a pre-built entity to reuse anywhere the graph encounters its source table:
$country = CountryFactory::new(['code' => 'DE'])->save();
$users = UserFactory::new()
->count(5)
->recycle($country)
->has(AddressFactory::new()->count(2)) // Address also belongsTo Country
->saveMany();
You end up with 5 users + 10 addresses + 1 country, not 5 users + 10 addresses + 15 countries. Pass several entities (or factories) to recycle() to cover multiple shared parents at once.
// Direct table access (replaces 1.x Factory::get($id))
$article = ArticleFactory::table()->get($id);
// Dedicated query starting point (replaces static Factory::find / count)
$published = ArticleFactory::query()->find('published')->all();
Three statics total: ::new(), ::from(), ::query(). Plus ::table() for the Table instance. That’s the whole static surface.
from()$article = $articlesTable->newEntity(['title' => 'Existing']);
$factory = ArticleFactory::from($article);
from(EntityInterface) keeps the entity’s identity intact — _accessible, _virtual, source alias all survive the trip. Unlike state(EntityInterface) which extracts via toArray(). v2 explicitly rejects combining from($entity) with count(>1) (it never produced N distinct entities anyway) and points you at the proper alternative in the error: new($entity->toArray())->count(N).
Declare the entity type once on the factory:
/**
* @extends \CakephpFixtureFactories\Factory\BaseFactory<\App\Model\Entity\Article>
*/
class ArticleFactory extends BaseFactory
{
// ...
}
…and PHPStan / Psalm resolve build(), buildMany(), save(), saveMany(), from() and friends to Article and array<Article> everywhere they’re called. No per-method overrides, no @phpstan-return magic.
If you have existing factories, the bundled FactoryAnnotatorTask keeps the docblocks in sync. With dereuromark/cakephp-ide-helper installed, bin/cake annotate classes (or annotate all) walks tests/Factory/ automatically.
TableAssertionsTrait adds a small set of assertion methods that read off Factory::query() with failure messages tuned for fixture-driven tests:
use CakephpFixtureFactories\TestSuite\TableAssertionsTrait;
class ArticleSyncServiceTest extends TestCase
{
use TableAssertionsTrait;
public function testSyncImportsExpectedRows(): void
{
ArticleFactory::new(['title' => 'Old', 'status' => 'draft'])->save();
$this->articleSyncService->run();
$this->assertTableCount(ArticleFactory::class, 3);
$this->assertTableHas(ArticleFactory::class, ['title' => 'New', 'status' => 'published']);
$this->assertTableMissing(ArticleFactory::class, ['status' => 'broken']);
}
}
Available helpers: assertTableHas, assertTableMissing, assertTableCount, assertTableEmpty, assertEntityExists, assertEntityMissing. Compared to hand-rolling $this->fetchTable('Articles')->find()->where(...)->count() in tests, the failure messages tell you which factory, which conditions, and what the actual rows look like instead of just “Failed asserting 2 matches 3”.
StoryThe new Story scenario abstract is for fixtures with a bit of structure — when you want a named pool of users (”Admins”, “Editors”), draw random members from it for related rows, and have the whole thing build in one call:
class BlogStory extends Story
{
public function build(): void
{
$this->addToPool('Admins', UserFactory::new(['role' => 'admin'])->count(2)->saveMany());
$this->addToPool('Authors', UserFactory::new(['role' => 'author'])->count(5)->saveMany());
foreach ($this->getPool('Authors') as $author) {
ArticleFactory::new()
->count(3)
->for($author)
->saveMany();
}
}
}
addToPool, getPool, getRandom, getRandomSet are the surface; existing FixtureScenarioInterface implementations keep working unchanged.
$fixtures arraysCakePHP’s classic fixture flow asks you to enumerate every table a test class might touch in a $fixtures property. That works, but it drifts: someone adds a Behavior that hits Logs, the test class doesn’t know, the fixture array doesn’t update, and you find out hours later when CI complains about leaked rows.
FactoryTransactionStrategy flips the model. Configure it once in config/app.php:
'TestSuite' => [
'fixtureStrategy' => \CakephpFixtureFactories\TestSuite\FactoryTransactionStrategy::class,
],
Every test then runs inside a transaction on the primary connection, opened at setupTest() and rolled back at teardownTest(). Anything written during the test — factories, direct $table->save($entity) calls, raw $connection->execute('INSERT ...') — is automatically reverted. No per-class $fixtures array, no manual cleanup.
Two extras worth knowing:
test, override via the protected string $primaryConnection property in a subclass). Secondary connections are tracked lazily through BaseFactory::save() / saveMany() — so a test that only uses one connection only opens one transaction.
unique() history and can’t trip OverflowException on a small value space.
For the rare cases that need real commits — code that depends on Model.afterSaveCommit, commit-triggered behaviors, or rows being durably visible across a separate connection — opt into the lazy variant per-class with LazyTransactionTrait, or fall back to Cake’s Eager strategy as a temporary pressure valve. The upgrade guide covers the trade-off.
The generator behind definition() is no longer hard-wired to Faker. v2 introduces a GeneratorInterface with two adapters in the box:
faker — fakerphp/faker, the well-known one, full locale catalogue, large provider list.
dummy — johnykvsky/dummygenerator, smaller and designed for deterministic, seeded output.
You don’t have to pick one in config. The resolver runs in this order:
$type argument to CakeGeneratorFactory::create() — wins if you pass one.
Configure::read('FixtureFactories.generatorType') — wins next, for projects that want to pin a choice.
Faker\Generator is loaded, use faker; otherwise fall back to DummyGenerator\DummyGenerator if that’s installed; throw a clear FixtureFactoryException with installation guidance when neither is available.
Faker stays the tiebreaker when both libraries are installed (preserving the prior default), but a project that only depends on johnykvsky/dummygenerator no longer needs to declare anything in config/app.php to make it work — the factory just picks it up.
Override globally or per call when the auto-detected default isn’t what you want:
// config/app.php — pin Dummy regardless of what's installed
'FixtureFactories' => [
'generatorType' => 'dummy',
],
// Or per call, scoped to one factory instance
$article = ArticleFactory::new()->setGenerator('dummy')->build();
Why pick one over the other?
mt_srand, which interacts with PHPUnit’s randomized test ordering and with anything else in the process touching mt_rand. Two CI runs of the same seeded test can drift because of factors outside the seed.
XoshiroRandomizer seeded explicitly through the adapter, so the sequence depends only on the seed and the call order on that specific generator instance. CI failures on row 47 of a 100-row factory output replay locally with the same seed. It’s also smaller and faster — no locale catalogue to load — at the cost of a narrower provider surface.
For reproducible test data either way, set the seed once:
'FixtureFactories' => [
'seed' => 1234,
'defaultLocale' => 'en_US', // explicit beats I18n fallback
],
If a test is flaky on the lowest-deps matrix and you can’t pin down why, switching that one test class to Dummy is often enough to confirm whether the flake was a Faker mt_srand interaction.
The package ships a Rector config that covers the safe, mechanical call-site changes:
vendor/bin/rector process tests --config vendor/dereuromark/cakephp-fixture-factories/rector.php
The bundled rules cover:
Factory::make(...) → Factory::new(...)
Factory::make($data, $n) → Factory::new($data)->count($n)
setDefaultTemplate() wrappers → definition(GeneratorInterface $generator)
getEntity() → build(), getEntities() → buildMany()
persistEntity() → save(), persistEntities() → saveMany()
patchData(...) → state(...) (in factory helper methods such as asAdmin())
Factory::find() → Factory::query()
Factory::get($id, $opts) → Factory::table()->get($id, $opts)
A few things rector intentionally doesn’t do — like rewriting deprecated persist() calls, because that return type is shape-dependent and needs a human choice between save() and saveMany(). The Factory::find() rule also splits by arity: zero-arg Factory::find() rewrites to Factory::query() directly (CakePHP 5’s SelectQuery::find() requires a finder name, so the chained form would error at runtime), while calls with an explicit finder name keep the chained Factory::query()->find('name') shape.
There’s also a small set of behavior changes since 1.4 that aren’t mechanical — setGenerator() is instance-scoped by default now, setDefaultTemplate() is no longer wired up (the rector handles the rewrite, but if you skip rector your factories produce empty data silently), FactoryTransactionStrategy is eager again on the primary connection. The full list is in the upgrade guide.
v2 is out and tested against a real sandbox app, currently available as 2.0.0-rc.1 for adopters who want to upgrade before the final tag drops. If you’re on the 1.4.x line, follow the upgrade guide — the migration tooling does most of the work, and the rest is documented as you hit it.
Issues, PRs, and “this confused me when I tried it” posts on the issue tracker all welcome.
A big thank-you to pabloelcolombiano and the vierge-noire team for building cakephp-fixture-factories in the first place. It shaped how a generation of CakePHP projects write tests (fast, factory-driven, no $fixtures-array gymnastics), and this continued version stands entirely on that foundation.
With the project not being maintained anymore in 2025, we decided to take over. The redesign is a continuation of their work.
The cakephp-audit-stash plugin has grown a lot of new surface between 1.x and the current 2.0. What started out as a behavior that records entity-level CRUD into an audit_logs table is now a full mini-app for observability: custom action events beyond CRUD, a dashboard, a coverage report, native chat alerting, and a streaming exporter.
This post walks through the highlights of the new major release, grouped by what they unlock for you as a maintainer of an audited Cake application.

2.0’s headline feature is custom action events. The audit trail is no longer limited to Created / Updated / Deleted rows tied to an entity — anything you want to leave a forensic record of can flow through the same persister, the same hash chain, and the same admin viewer.
use AuditStash\Audit;
Audit::log(
type: 'user.login',
source: 'Users',
primaryKey: $user->id,
data: ['ip' => $request->clientIp()],
meta: ['user_id' => $user->id, 'user_display' => $user->name],
);
Behind the scenes:
AuditStash\Audit static facade dispatches the event.
EventFactory falls back to a new AuditCustomEvent for unknown types.
audit_logs.type widened from VARCHAR(7) to VARCHAR(64) so dotted scope strings fit.
BC note: $auditLog->type is now a plain string instead of the AuditLogType enum. If you need the enum form, call AuditLogType::tryFrom($log->type).
The plugin now ships an at-a-glance dashboard at the admin root (configurable via AuditStash.routePath) so you stop landing on a paginated list as the first thing you see.
What’s on it:
AuditHelper::eventTypeBadge and formatRecord helpers.
Alongside it, a new Coverage report at /admin/audit-stash/coverage answers the question “which of my tables are actually being audited?”:
Table classes from the app and every loaded plugin via Plugin::getCollection().
AuditStash.coverage.hidePlugins / AuditStash.coverage.hideTables.
AuditMonitor already supported alert delivery, but until now you had to hand-write a Channel subclass to get a readable Slack or Discord message. The new release ships two platform-native channels you can drop in directly:
'channels' => [
'class' => SlackChannel::class,
'url' => env('SLACK_WEBHOOK_URL'),
],
SlackChannel uses Block Kit (header / section / fields blocks) with a severity-colored attachment. Optional username / icon_emoji / channel overrides. Fields are mrkdwn-escaped, so a source containing < or @everyone can’t smuggle markup or pings.
DiscordChannel uses the embed format with a decimal-RGB sidebar color and inline fields. Sets allowed_mentions: { parse: [] } defensively, omits the timestamp key when the audit row has no created, and normalises empty / null field values to n/a so Discord doesn’t reject the payload.
The shared HTTP, retry and error-logging plumbing was extracted into a new AbstractWebhookChannel — the documented extension point for whatever platform-native schema your tenant prefers.
Teams users: there’s no bundled TeamsChannel because Microsoft is sunsetting MessageCard incoming webhooks in favor of Adaptive Cards via Power Automate Workflows, which has a fundamentally different setup and trigger model. The Building your own channel docs section points at AbstractWebhookChannel as the extension seam for whatever schema your tenant currently accepts.
Channels are the happy-path delivery mechanism — but they’re a closed set. Anything beyond the bundled platforms (per-context suppression, alert mutation, forwarding to Sentry, custom incident stores) used to require subclassing.
Two new events on the global EventManager fire around the existing rule-check / alert-send flow:
| Event | When | What you can do |
|---|---|---|
AuditStash.Monitor.beforeAlert |
After rule.matches() and createAlert(), before any channel runs |
stopPropagation() to suppress; setData('alert', $new) to replace |
AuditStash.Monitor.afterAlert |
After every channel finished | Inspect the [channelName => bool] results map for partial failures |
Rule-failure routing went a different way: instead of a third event, the rule-failure logger call now passes the full Throwable ('exception' => $e) so PSR-3 handlers like Monolog’s IntrospectionProcessor or the sentry/sentry Cake bridge pick up the stack natively. No new API surface, full forwarding to your existing error pipeline.
Export is now its own page, not an inline button. Three pieces:
AuditStash\Service\ExportService — streams the query in configurable batches (AuditStash.export.batchSize, default 1000), pre-flights with a count() against AuditStash.export.hardCap (default 100000), and refuses oversized exports with BadRequestException rather than silently truncating.
/admin/audit-logs/export form page showing the active-filter summary, row-count estimate, format picker (CSV / JSON / NDJSON), and a disabled submit button when the cap would be exceeded.
php://temp + Laminas\Diactoros\Stream — bounded PHP-process memory, spills to disk past 2 MB.
NDJSON joins CSV and JSON for streaming-friendly machine consumers. Filters carry from the index page through to the form via query string and on through to the streaming download URL — so the row count you confirm is the row count you get. The default 30-day created-at floor is skipped when other narrowing filters (source, primarykey, transactionkey, etc.) are already set, so a deliberately-narrowed view never silently exports zero rows.
This is the change most likely to bite on upgrade, and it’s deliberate.
Audit logs commonly contain who-did-what records — PII, IP addresses, before/after field values for every change. An accidentally forgotten host-side route guard would expose more than a typical admin page. So the plugin now refuses to serve any admin action unless AuditStash.adminAccess is explicitly set to a Closure. A missing config key, a non-Closure value, a Closure that returns anything other than literal true, or a Closure that throws — all yield a 403.
The config key was also renamed from accessCheck to adminAccess to align with the cakephp-queue posture and to read more naturally (describes what is gated rather than the function shape).
Escape hatch for users who want to delegate fully to their host AppController auth:
'AuditStash' => [
'adminAccess' => fn() => true,
],
That’s now an explicit “I trust the upstream guard” choice rather than an accidental forgotten gate.
Two opt-in observability additions:
EnvironmentMetadata learned a new capture constructor argument so applications can opt in to request-derived meta fields:
new EnvironmentMetadata(
request: $request,
capture: ['user_agent', 'referer', 'session_id'],
);
Off by default because these can carry PII / GDPR implications. Empty headers and inactive sessions are skipped so the audit row never gains an empty-string column. Unknown field names are filtered against an allow-list, so a typo can’t smuggle arbitrary values into meta.
SensitiveFieldRule is a new monitor rule that fires when a configured field on a configured table appears in an audit row’s changed (create / update) or original (delete) payload. Mirrors MassDeleteRule’s shape, slots into the existing channel pipeline with no infrastructure changes, defaults to high severity. Ideal for “alert me when anyone touches users.password_hash” style rules.
This one actually shipped back in 1.1.0 but never got a proper writeup, so it’s worth covering alongside 2.0: an opt-in SHA-256 hash chain over persisted audit rows, giving AuditStash the integrity guarantees that regulated environments — GoBD (DE), SOX (US), HIPAA, and friends — expect from the audit trail itself.
Each persisted row carries two new columns — prev_hash (the previous row’s hash) and hash (SHA-256 over the canonicalized current row plus that previous link). Editing any historical row breaks the chain at that row and every row after it; a verifier walking the table catches the break and points at the offending row.
Disabled by default. To turn it on:
'persisterConfig' => [
'hashChain' => true,
],
…plus the new migration that adds prev_hash / hash / idx_hash. Rows written before the migration stay NULL in both columns — the chain simply anchors at the first row written after you flip the flag, so there’s no destructive backfill step.
A few mechanics worth flagging:
bin/cake audit_stash verify_chain [--table=... --chunk=...] — streams the table in bounded memory and exits 1 on the first broken link with a human-readable reason. Drop it in cron or CI.
logEvents() batch runs in a single transaction; on MySQL / Postgres the chain tail is read with SELECT ... FOR UPDATE so concurrent writers serialize on it instead of orphaning links. SQLite’s database-level locking gives the equivalent guarantee.
display_value) verify cleanly without payload divergence across installs.
save() failure mid-batch throws RuntimeException rather than silently dropping a row and breaking the chain at the tail.
Only TablePersister implements this — the Elastic Search persister can’t offer the same ordering guarantee, and the flag is ignored there. If you need tamper-evidence on Elastic, route audit events through SQL first and replicate downstream.
Full rationale, concurrency semantics, and the truncation-at-tail limitation (plus anchoring / heartbeat mitigations) are written up in docs/tamper-evidence.md.
Audit rows are not the place to stash uploaded file blobs. The clean approach is a virtual field on the entity that exposes a stable fingerprint — a hash over the stored bytes, a CDN ETag, byte length, whatever is cheap and deterministic for your storage:
// src/Model/Entity/Document.php
protected function _getFingerprint(): ?string
{
return $this->file_path
? hash_file('sha256', WWW_ROOT . $this->file_path)
: null;
}
Virtual fields participate in entity diffs like any other column, so the audit row records “fingerprint changed from abc… to def…” without your audit table ever seeing the file body. No audit-side machinery required.
For legacy schemas where you can’t add a virtual field — uploads tracked in a sibling join table the audited entity doesn’t know about, for example — there’s a AuditStash.beforeLog event hook that fires before the audit row is persisted. One caveat called out explicitly in the docs: BaseEvent has no public setter for changed / original, so the event-hook path needs a small persister decorator to actually merge your additions in. The pattern is in docs/usage.md.
For the related case of recording that a sensitive field changed without storing the value itself, the existing 'sensitive' behavior config is still the right tool — no new machinery there either.
If you maintain a downstream application that uses audit-stash, you’ve probably written a one-off helper to assert “this controller action produced an audit row”. There’s now a shipped one:
use AuditStash\TestSuite\AuditAssertionsTrait;
class OrdersControllerTest extends TestCase
{
use AuditAssertionsTrait;
public function testCreateLogsAuditEntry(): void
{
$this->post(['controller' => 'Orders', 'action' => 'add'], [...]);
$this->assertAuditLogged('Orders');
$this->assertAuditFieldChanged('Orders', 'status', null, 'pending');
}
}
Mixes into any TestCase that loads plugin.AuditStash.AuditLogs. Exposes assertAuditLogged, assertAuditNotLogged, assertAuditCount, assertAuditFieldChanged, plus a buildAuditQuery seam for custom assertions. Queries the audit_logs table directly so tests verify what was persisted, not just what an in-memory event queue produced.
The new docs/testing.md covers the full reference and the buildAuditQuery extension seam.
If you’re upgrading from 1.x to 2.0, the two things to look at on the way in are:
AuditStash.adminAccess to an explicit Closure — even fn() => true is fine if you trust your upstream guard. A missing config key now yields a 403.
audit_logs.type to VARCHAR(64). It also drops the EnumType mapping on the column, which means $auditLog->type now returns a string instead of an AuditLogType enum.
Everything else is additive.
Feedback, bug reports, and feature requests (ideally as PRs) welcome over at the GitHub repo.
Remember CakePHP 2’s ACL? The ACO/ARO trees, the aros_acos join table, the tutorial that taught a whole generation of us what “hierarchical permissions” even meant? That was a big idea for its time — permissions as data, managed at runtime, not baked into code. A lot of us learned authorization concepts from that component, and the DNA of today’s tools goes right back to it.
Some people remember: It was painful and slow to work with it, though.
Then the ecosystem evolved. CakePHP 3, 4, and 5 shipped cakephp/authorization and cakephp/authentication — clean, policy-based, composable. TinyAuth kept the lightweight INI-file style alive for teams who wanted configuration over code. Both approaches are great at what they do.
Maybe you also read my last blog post about authz topic.
The one thing that stayed a little wistful was the admin-UI story. Policies live in code; INI files live on disk; and every so often a project would ask, “Can ops toggle this without a deploy?” The honest answer used to be “not really”. That’s the gap TinyAuth Backend 3.x closes — and it’s the reason ACL, as a concept, is quietly having a comeback.
TinyAuth Backend 3.x is a full rewrite. The breaking changes are real — the old tiny_auth_allow_rules and tiny_auth_acl_rules tables are dropped by the migration, PHP 8.2 and CakePHP 5.1 are the new minimums, and existing permissions need to be re-imported or recreated. The payoff is a plugin that feels native to modern CakePHP instead of bolted on around it.

The easiest way to understand the shape of the rewrite is to look at the schema, the UI, and the integration points side by side.
The old 2-table layout has been replaced with eight properly normalized tables:
| Table | Purpose |
|---|---|
tinyauth_roles |
Roles, with parent/child hierarchy |
tinyauth_controllers |
Discovered controllers (plugin / prefix / name) |
tinyauth_actions |
Controller actions, with a public flag |
tinyauth_acl_permissions |
Role-to-action grants, with optional rule descriptions |
tinyauth_resources |
Entity resources for resource-based auth |
tinyauth_resource_abilities |
Abilities per resource (view, edit, delete, publish, …) |
tinyauth_scopes |
Reusable conditions (e.g. “own records”, “same team”) |
tinyauth_resource_acl |
Resource-to-role grants, with scope support |
Two things stand out here. First, every row is addressable on its own — you can join, query, export, audit, and diff permissions with plain SQL. Second, abilities and scopes are reusable data, not hand-written policy classes. Define own once, apply it to Articles, Projects, Comments, and anything else that has a user_id. That’s the kind of reuse that gets expensive when permissions live in code.
The normalized layout is also what makes features like rule descriptions, inherited-permission rendering, and one-click sync possible at all — they all read from the same tables the runtime enforcement uses.
The admin panel at /admin/auth/ is written with HTMX + Alpine.js + Tailwind CSS. Toggling a permission is a partial update — no full page reload, no lost scroll position, no “did it save?” anxiety. The layout is standalone: the plugin ships its own chrome, so you don’t have to wrestle your host app’s layout into rendering an admin screen, and it comes with light and dark themes out of the box.
A few details that add up:


None of these are visual polish for its own sake — they exist because once you’re managing permissions in a UI, the UI has to make intent visible. A green dot is information; a green dot with a tooltip explaining why is a conversation your team doesn’t have to have in Slack.
A runtime permission system is only useful if it knows what actions and resources exist. TinyAuth Backend 3.x ships auto-discovery for both:
ControllerSyncService) walks your application (and plugins, and prefixes) and writes discovered controllers and actions into tinyauth_controllers / tinyauth_actions. Added a new action this morning? Click Sync in the admin panel at /admin/auth/sync and it appears in the matrix.
ResourceSyncService) discovers entity resources and their abilities, so resource-level authorization stays in step with your models.
The sync is idempotent — re-running it won’t clobber your existing grants. Actions that appear in code get added; existing rows are left alone. (Orphans from deleted controllers aren’t auto-pruned yet — you’ll want to clean those up by hand or with a quick SQL query.) Permissions management stops being a manual catch-up chore.
If you already have a TinyAuth app with auth_allow.ini and auth_acl.ini files, you don’t have to rebuild your permissions from scratch:
bin/cake tiny_auth_backend import allow
bin/cake tiny_auth_backend import acl
That’s the upgrade path from plain TinyAuth to the backend — run the import, and your INI rules show up in the admin UI as editable rows. Going the other way, ImportExportService can export the whole permission set to JSON or CSV, which is genuinely useful for diffing environments, seeding staging, or attaching a snapshot to a pull request.
And if you’re not ready to commit fully? The composite adapters (CompositeAllowAdapter / CompositeAclAdapter) let you keep your existing INI files active and layer DB-backed rules on top, served by a single adapter slot. That’s the gradual-adoption path: switch on the backend, import what you want to migrate, leave the rest in INI, and move rules over at your own pace.
The admin panel is at /admin/auth/, which is exactly the kind of URL you want gated. Rather than force you to wire this into your host application’s middleware, TinyAuth Backend 3.x adds a plugin-level hook:
// config/app.php
use Psr\Http\Message\ServerRequestInterface;
'TinyAuthBackend' => [
'editorCheck' => function (mixed $identity, ServerRequestInterface $request): bool {
return $identity !== null && in_array('admin', (array)$identity->roles, true);
},
],
The callable receives the current identity and the request, and runs before every /admin/auth/* action. Return true and you’re in; return anything else and the plugin rejects the request with a 403. Your host application’s middleware stack stays clean, and the gating rule lives next to the plugin config.
Not every project needs every feature. The backend exposes five main capabilities as independently togglable features — allow, acl, roles, resources, and scopes — and by default each one auto-enables if its backing table exists. That means you can run migrations selectively (say, just tinyauth_actions for public-action management) and the rest simply stays out of the way.
When you want explicit control, the TinyAuthBackend.features config overrides auto-detection:
// config/app.php
'TinyAuthBackend' => [
'features' => [
'allow' => true, // force enabled
'acl' => true,
'roles' => true,
'resources' => false, // force disabled
'scopes' => false,
],
],
Disabled features disappear from the admin navigation entirely — no dead links, no half-rendered pages pointing at tables that don’t exist. This is how you adopt the plugin one capability at a time: start with just allow as a UI for your public-action list, add acl when you want role-level gating, and turn on resources + scopes the day you need entity-level authorization. Each step is a config flag and a migration, not a commitment to the whole stack.

Roles don’t have to live in users.role_id. RoleSourceService supports four role source styles out of the box:
Configure path (for roles defined in config at deploy time)
Whichever source you pick, everything downstream — scopes, hierarchy, matrix UI, policy integration — keeps working unchanged. This is the mechanism that makes the ExternalRoles rung below possible, and it’s a genuinely nice separation of concerns.
cakephp/authorization integrationThe policy side is built around four small pieces, all shipped by the plugin:
TinyAuthPolicy plugs into cakephp/authorization as a regular policy class. It ships with both entity-level can*() methods and scopeIndex() / scopeView() built in, so $this->Authorization->applyScope($query) narrows list results through the same DB rules that govern entity access. One source of truth for “can see” and “can edit”, no subclassing required.
TinyAuthResolver is a ResolverInterface implementation that maps every known entity, table, or SelectQuery to TinyAuthPolicy — transparently unwrapping queries to their repository so the same resolver works for both authorize($entity) and applyScope($query). Cake’s built-in MapResolver fails at the query path, and OrmResolver forces convention-based App\Policy\* classes; TinyAuthResolver avoids both. Pass it an allowlist of classes, or leave it empty to govern everything.
EntityIdentity is a minimal IdentityInterface wrapper around a Cake entity, for apps that resolve users from a session, a JWT claim, or an SSO gateway and don’t load cakephp/authentication. The authorization service argument is optional — without it, can() returns false and applyScope() is a pass-through, which is the correct behavior for role-only strategies.
TinyAuthService is the programmatic entry point — canAccess($roles, $resource, $ability, $entity, $user) for checks (the first argument is a role alias or an array of aliases), getScopeCondition(...) for query filtering.
All of the above can be adopted incrementally. The demo app ships four usage strategies, arranged as a ladder. Start on the rung that fits where your project is today, and climb as needs grow. Every rung is a legitimate destination — you don’t have to reach the top to “win”.
You have a CakePHP app. You have an auth_allow.ini. You’d like non-developers to be able to adjust who-can-do-what without a pull request.
AdapterOnly is made for that. No cakephp/authorization component, no policy classes — just role-level request gating, with the admin panel as a friendly front door to the same data. The migration is small, and the mental model barely changes.
// AdapterOnly: role-level request gating, nothing more.
// TinyAuthBackend reads from the DB, the admin UI writes to it,
// your controllers keep doing exactly what they were doing.
A short path to a real win: ops gets a UI, you get your afternoon back.
This is the rung where it becomes the thing you probably pictured when you first read the phrase resource permissions.
Load cakephp/authorization and wire the plugin-provided TinyAuthResolver into your authorization service. One constructor call, one allowlist:
// Application::getAuthorizationService()
use TinyAuthBackend\Policy\TinyAuthResolver;
$resolver = new TinyAuthResolver([
\App\Model\Entity\Article::class,
\App\Model\Entity\Project::class,
]);
return new AuthorizationService($resolver);
That’s the whole wiring. TinyAuthResolver maps both entities and queries to the plugin’s TinyAuthPolicy, transparently unwrapping SelectQuery instances to their repository so the same resolver works for both $this->Authorization->authorize() and ->applyScope(). With that in place, the following works out of the box:
public function edit(string $id)
{
$article = $this->Articles->get($id);
$this->Authorization->authorize($article, 'edit');
// ...
}
public function index()
{
$query = $this->Authorization->applyScope($this->Articles->find(), 'index');
// Query now filtered by the user's scope — "own", "team", whatever.
}
Under the hood, authorize() runs through TinyAuthPolicy::can() → TinyAuthService → DB rules → role hierarchy → scopes. Four layers, one call. The demo wires this up for articles (scoped by user_id) and projects (scoped by team_id), and the scope definitions — own, team, department, company — are reusable rows in tinyauth_scopes rather than hand-written policy classes.


The best part: the matrix UI and the runtime enforcement read the same data. If a cell lights up green in the admin panel, it lights up green in the controller. That alignment is genuinely rare, and it pays for itself the first time you debug a permission question.
A sibling of FullBackend with a different wiring diagram. Teams already running cakephp/authentication can keep owning the identity side of the stack; TinyAuth contributes the policy layer and stays out of the middleware conversation.
The enforcement code is identical — the demo’s NativeAuth controllers literally extend the FullBackend ones. That’s the point: moving between rungs is a wiring change, not a rewrite.
Sooner or later, the role stops living in users.role_id. It lives in a JWT claim, an LDAP group, a session from an upstream SSO gateway. ExternalRoles supports exactly that: swap TinyAuthBackend.roleSource for a callable (the demo uses a session-backed one via StrategyMiddleware), and everything else — scopes, hierarchy, the matrix UI, the policy layer — keeps working unchanged.
Changing where roles come from without changing how permissions work is the whole reason this rung exists, and it’s a genuinely nice separation.
The rewrite leans hard on small, focused services. If you want to build tooling around the plugin — custom import formats, a different UI, a scheduled sync — these are the handles:
TinyAuthService — central permission checking
HierarchyService — role hierarchy traversal and inheritance resolution
ControllerSyncService — controller/action discovery from your application
ResourceSyncService — entity resource/ability discovery
ImportExportService — JSON/CSV export and legacy INI import
FeatureService — enable/disable optional features at runtime
RoleSourceService — flexible role data source resolution
Each one has a single job and a small surface area. Together they’re what makes the admin UI, the CLI commands, and the authorization integration feel like parts of the same system rather than three things stapled together.
A few things worth celebrating about where TinyAuth Backend 3.x landed:
cakephp/authorization. It plugs into the framework’s policy layer rather than replacing it.
In spirit, yes. The concept that made ACL interesting in the first place — configure permissions as data, manage them in a UI, enforce them at runtime — is back, and it’s wearing modern CakePHP underneath. Normalized schema, reactive UI, first-class policy integration, flexible role sources, and a gradual adoption path that meets your project where it actually is.
Pick a rung, give it a spin, and see how far up the ladder your project wants to climb. The linked demo app below also has strict CSP and showcases that it works with strictest SecurityHeadersMiddleware implementations for maximum safety.
Links
Do not use env('REMOTE_ADDR') or low level env() wrapper directly. Those are always the direct TCP connection source, not necessarily the real IP.
Make sure to always use ServerRequest::clientIp() when interacting with the user’s IP address.
When you move a CakePHP application behind a reverse proxy – for example, switching from PHP-FPM to FrankenPHP in Docker behind nginx, this issue becomes visible. Now env('REMOTE_ADDR') holds the internal IP of the proxy (e.g. 172.22.0.1 for Docker’s bridge network).
This breaks anything that relies on the raw environment variable:
You’ll typically notice it first in the logs – every request line shows the same address:
[2026-04-09 14:22:01] login.INFO: User logged in {"user_id":42,"ip":"172.22.0.1"}
[2026-04-09 14:22:07] login.INFO: User logged in {"user_id":17,"ip":"172.22.0.1"}
[2026-04-09 14:22:11] login.WARN: Failed login attempt {"ip":"172.22.0.1"}
That’s the Docker bridge gateway, not your visitors.
CakePHP’s ServerRequest::clientIp() is proxy-aware. When trusted proxies are configured, it reads the real client IP from the X-Forwarded-For or X-Real-IP headers that the reverse proxy sets. When no proxy is involved, it falls back to REMOTE_ADDR – so it works correctly in both environments.
class TrustedProxyMiddleware implements MiddlewareInterface {
public function process(
ServerRequestInterface $request,
RequestHandlerInterface $handler,
): ResponseInterface {
if ($request instanceof ServerRequest) {
$trustedProxies = Configure::read('App.trustedProxies');
if ($trustedProxies) {
$request->setTrustedProxies((array)$trustedProxies);
}
}
return $handler->handle($request);
}
}
Register it early in your middleware queue – before the error handler.
In app_local.php (or app.php):
'App' => [
'trustedProxies' => [
'127.0.0.1',
'172.16.0.0/12',
'10.0.0.0/8',
'192.168.0.0/16',
],
],
This tells CakePHP to trust X-Forwarded-For headers from these addresses (your local network and Docker subnets).
[!CAUTION] Never add
0.0.0.0/0or any public IP range totrustedProxies. If you do, any client on the internet can spoof their IP simply by sending anX-Forwarded-Forheader. Only list addresses you actually control – your proxy, your load balancer, your Docker subnets.
If your traffic goes through more than one proxy – e.g. Cloudflare → nginx → app – the X-Forwarded-For header becomes a comma-separated list like 203.0.113.5, 198.51.100.7, 172.22.0.1. CakePHP walks that list from right to left, skipping addresses that match trustedProxies, and returns the first untrusted one as the real client.
For this to work, you must trust every intermediate proxy IP. With Cloudflare in front, that means adding Cloudflare’s published IP ranges to your trusted list – otherwise CakePHP will stop at the Cloudflare edge IP and treat that as the client.
real_ip moduleYou can also fix this at the web server layer using nginx’s ngx_http_realip_module, which rewrites REMOTE_ADDR before the request reaches PHP. It works, but the application-level approach is usually preferable:
Search your codebase for direct REMOTE_ADDR usage:
env('REMOTE_ADDR')
$_SERVER['REMOTE_ADDR']
Replace each occurrence with $request->clientIp() or $this->request->clientIp() depending on context.
If you don’t have a request object in scope, fetch the current one statically via Router::getRequest():
use Cake\Routing\Router;
$ip = Router::getRequest()?->clientIp();
This returns the same ServerRequest that already passed through your TrustedProxyMiddleware, so clientIp() stays proxy-aware.
Caveats:
null in CLI/shell context or before the request has been dispatched – always null-check.
clientIp() is correct in both scenarios. There is no reason to use env('REMOTE_ADDR') directly in application code.
This is especially important if you are a plugin maintainer. As this code is then not directly “adjustable” from the developer’s perspective. So that will cause a bug ticket at some point otherwise.
The easiest way to confirm everything is wired up correctly is to use the cakephp-setup plugin, which ships with a built-in IP debug page at /admin/setup/backend/ip. It shows you exactly what clientIp() returns, what headers came in, and which proxies were trusted – so you can spot misconfigurations at a glance.
If you’d rather not pull in the plugin, a tiny debug route does the same job:
$routes->get('/debug-ip', function ($request) {
return new Response(['body' => $request->clientIp()]);
});
Curl it from inside and outside the proxy and confirm you see your real public IP, not the proxy’s internal address.
clientIp() only makes sense during an HTTP request. If you have a queue worker or shell command that needs the visitor’s IP – say, to send a “new login from $ip” email asynchronously – you can’t recover it after the fact. Capture the IP at request time and persist it into the job payload, then read it from there in the worker.
If you run CakePHP behind any reverse proxy – Docker, nginx, a load balancer, Cloudflare – always use ServerRequest::clientIp() with trusted proxies configured. It’s a one-time setup that prevents a whole class of subtle bugs.
A Complete Guide to php-collective/toml
TOML has gained traction as a configuration format. Rust’s Cargo, Python’s pyproject.toml, and various CLI tools use it. For PHP projects that need to read or write TOML files, php-collective/toml provides a modern parser and encoder with AST access.
Configuration formats involve trade-offs. YAML offers flexibility but brings complexity. JSON lacks comments and trailing commas. PHP arrays work but aren’t portable. TOML aims for a middle ground: human-readable, unambiguous, and easy to parse.
For those unfamiliar with TOML, here’s a quick overview of the format.
Basic key-value pairs:
title = "My Application"
version = 1.2
enabled = true
Tables (sections):
[database]
host = "localhost"
port = 5432
[database.credentials]
username = "admin"
password = "secret"
Arrays:
ports = [8080, 8081, 8082]
hosts = ["alpha", "beta", "gamma"]
Array of tables:
[[servers]]
name = "alpha"
ip = "10.0.0.1"
[[servers]]
name = "beta"
ip = "10.0.0.2"
Inline tables:
point = { x = 1, y = 2 }
database = { host = "localhost", port = 5432 }
Multiline strings:
description = """
This is a longer description
that spans multiple lines.
Whitespace is preserved."""
regex = '''\\d+\\.\\d+'''
Dates and times:
created = 2024-01-15T10:30:00Z
date_only = 2024-01-15
time_only = 10:30:00
Each format has its quirks. Here’s how they compare in practice.
The “Norway problem” – unquoted strings becoming booleans:
# YAML 1.1 and some legacy parsers: This becomes boolean false
country: NO
# YAML 1.1 and some legacy parsers: These also become booleans
answer: yes
enabled: on
# TOML: Always a string, no ambiguity
country = "NO"
Whitespace sensitivity:
# YAML: Indentation matters - this is valid
database:
host: localhost
port: 5432
# YAML: This breaks everything
database:
host: localhost
port: 5432 # Wrong indentation = parse error or wrong structure
# TOML: Indentation is purely cosmetic
[database]
host = "localhost"
port = 5432 # Works fine, though unconventional
Type ambiguity:
# YAML: Is this a string or a number?
version: 1.0 # Parsed as float 1.0
version: "1.0" # Parsed as string "1.0"
version: 1.0.0 # Parsed as string "1.0.0" (silently)
# YAML: Octal numbers surprise
permissions: 0755 # Parsed as decimal 493 in YAML 1.1
# TOML: Explicit typing required
version = 1.0.0 # Parse error - invalid syntax
version = "1.0.0" # String (must quote it)
count = 42 # Integer
ratio = 3.14 # Float
NEON specifics:
# NEON: Entity syntax (used in Nette DI)
service: App\MyService(@dependency, %parameter%)
# NEON: Concise syntax works well for Nette-style configuration
# but entity syntax does not map directly to TOML
| Aspect | TOML | YAML | NEON |
|---|---|---|---|
| Whitespace-sensitive | No | Yes | Partial |
| Comments | # |
# |
# |
| Multiline strings | Yes | Yes | Yes |
| Native datetime | Yes | Yes | No |
| Type ambiguity | Minimal | Significant | Moderate |
| Nested depth | Verbose | Concise | Concise |
| Spec complexity | Simple | Complex | Moderate |
Here’s the same configuration expressed in all three formats:
# Application configuration
title = "My App"
version = "2.1.0"
debug = false
[database]
host = "localhost"
port = 5432
name = "myapp"
pool_size = 10
[database.credentials]
username = "admin"
password = "secret"
[cache]
driver = "redis"
ttl = 3600
[[servers]]
name = "alpha"
ip = "10.0.0.1"
roles = ["web", "api"]
[[servers]]
name = "beta"
ip = "10.0.0.2"
roles = ["worker"]
[logging]
level = "info"
format = "json"
created = 2024-01-15T10:30:00Z
# Application configuration
title: My App
version: "2.1.0"
debug: false
database:
host: localhost
port: 5432
name: myapp
pool_size: 10
credentials:
username: admin
password: secret
cache:
driver: redis
ttl: 3600
servers:
- name: alpha
ip: 10.0.0.1
roles:
- web
- api
- name: beta
ip: 10.0.0.2
roles:
- worker
logging:
level: info
format: json
created: 2024-01-15T10:30:00Z
# Application configuration
title: My App
version: "2.1.0"
debug: false
database:
host: localhost
port: 5432
name: myapp
pool_size: 10
credentials:
username: admin
password: secret
cache:
driver: redis
ttl: 3600
servers:
- name: alpha
ip: 10.0.0.1
roles: [web, api]
- name: beta
ip: 10.0.0.2
roles: [worker]
logging:
level: info
format: json
created: 2024-01-15T10:30:00Z
Full TOML 1.0/1.1 support with strict validation for keys, tables, strings, numbers, and datetimes. The project also publishes a support matrix for current coverage and known gaps.
Error recovery – Rather than stopping at the first problem, it can collect multiple errors. Useful for tooling and editor integrations:
$result = Toml::tryParse($tomlString);
foreach ($result->getErrors() as $error) {
echo $error->format($tomlString);
}
Simple API for common operations:
$config = Toml::decodeFile('config.toml');
Toml::encodeFile('output.toml', $data);
Separate Lexer/Parser/AST – The architecture allows direct AST access for analysis without full evaluation.
No required extensions – Works out of the box on PHP 8.2+. The php-ds extension is optional for performance.
When parsing invalid TOML, the library can continue past the first error and collect multiple problems. This is particularly valuable for editor integrations and linters where you want to surface all detected issues at once:
$result = Toml::tryParse($tomlString);
if ($result->hasErrors()) {
foreach ($result->getErrors() as $error) {
echo $error->format($tomlString);
}
}
Each error includes line and column information, making it straightforward to report precise locations in CLI output or IDE diagnostics.
For tools that need to analyze configuration structure without evaluating it, such as linters, formatters, or editor plugins, direct AST access is available:
use PhpCollective\Toml\Toml;
use PhpCollective\Toml\Ast\Table;
use PhpCollective\Toml\Ast\KeyValue;
$document = Toml::parse($tomlString);
foreach ($document->items as $node) {
if ($node instanceof Table) {
echo "Found table: " . implode('.', $node->key->parts) . "\n";
} elseif ($node instanceof KeyValue) {
echo "Found key: " . implode('.', $node->key->parts) . "\n";
}
}
This separation means you can traverse the document structure, check for specific patterns, or even implement custom validation rules beyond what TOML itself requires.
Application configuration:
// config/app.toml
$config = Toml::decodeFile(__DIR__ . '/config/app.toml');
$dbHost = $config['database']['host'];
$cacheDriver = $config['cache']['driver'] ?? 'file';
Environment-specific settings:
$env = getenv('APP_ENV') ?: 'development';
$config = Toml::decodeFile(__DIR__ . "/config/{$env}.toml");
Reading Python project configuration:
// Parse a pyproject.toml to extract dependencies or metadata
$pyproject = Toml::decodeFile('/path/to/pyproject.toml');
$projectName = $pyproject['project']['name'];
$dependencies = $pyproject['project']['dependencies'] ?? [];
$pythonVersion = $pyproject['project']['requires-python'];
Plugin or package metadata:
# plugin.toml
[plugin]
name = "My Plugin"
version = "2.1.0"
author = "Jane Doe"
[plugin.requirements]
php = ">=8.2"
extensions = ["json", "mbstring"]
[[plugin.hooks]]
event = "beforeSave"
handler = "App\\Hooks\\ValidateData"
[[plugin.hooks]]
event = "afterSave"
handler = "App\\Hooks\\ClearCache"
These examples are not all equivalent.
config/*.php.
CakePHP – Config engine integration:
// src/Configure/Engine/TomlConfigEngine.php
namespace App\Configure\Engine;
use Cake\Core\Configure\ConfigEngineInterface;
use Cake\Core\Exception\CakeException;
use PhpCollective\Toml\Toml;
class TomlConfigEngine implements ConfigEngineInterface
{
protected string $path;
public function __construct(string $path = CONFIG)
{
$this->path = $path;
}
public function read(string $key): array
{
$file = $this->path . $key . '.toml';
if (!is_file($file)) {
throw new CakeException("Could not load configuration file: {$file}");
}
return Toml::decodeFile($file);
}
public function dump(string $key, array $data): bool
{
$file = $this->path . $key . '.toml';
return file_put_contents($file, Toml::encode($data)) !== false;
}
}
// Register in bootstrap.php
Configure::config('toml', new TomlConfigEngine());
Configure::load('app_local', 'toml');
This is a proper CakePHP integration point because Configure is designed to work with custom engines.
Symfony – Custom parameter import pattern:
// src/DependencyInjection/TomlExtension.php
namespace App\DependencyInjection;
use PhpCollective\Toml\Toml;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Extension\Extension;
class TomlExtension extends Extension
{
public function load(array $configs, ContainerBuilder $container): void
{
$configFile = $container->getParameter('kernel.project_dir') . '/config/app.toml';
if (file_exists($configFile)) {
$tomlConfig = Toml::decodeFile($configFile);
$container->setParameter('app.toml', $tomlConfig);
}
}
}
This can work in a bundle or application-specific extension, but it is still a custom pattern. Symfony’s standard configuration formats are YAML, XML, and PHP, so this is better framed as importing TOML-derived parameters than as native Symfony config loading.
Laravel – Boot-time import pattern:
// app/Providers/TomlConfigServiceProvider.php
namespace App\Providers;
use Illuminate\Support\ServiceProvider;
use PhpCollective\Toml\Toml;
class TomlConfigServiceProvider extends ServiceProvider
{
public function boot(): void
{
$tomlConfig = config_path('app.toml');
if (file_exists($tomlConfig)) {
config()->set('toml', Toml::decodeFile($tomlConfig));
}
}
}
This is useful when you want TOML-backed application settings inside a Laravel app, but it is not native Laravel config-file support. Keeping the imported data under a dedicated toml key avoids accidentally overwriting existing framework or package config keys.
Converting existing configuration files is straightforward. Here’s a helper approach:
use PhpCollective\Toml\Toml;
use Symfony\Component\Yaml\Yaml;
// Load existing YAML
$yamlData = Yaml::parseFile('config.yaml');
// Write as TOML
Toml::encodeFile('config.toml', $yamlData);
For NEON (Nette):
use PhpCollective\Toml\Toml;
use Nette\Neon\Neon;
$neonContent = file_get_contents('config.neon');
$neonData = Neon::decode($neonContent);
// Filter out NEON-specific constructs like entities
// that don't translate directly to TOML
$cleanData = array_filter($neonData, fn($v) => !is_object($v));
Toml::encodeFile('config.toml', $cleanData);
Some things to watch for during migration:
@service, %parameter%) have no TOML equivalent
composer require php-collective/toml
Requires PHP 8.2+. Optional: install php-ds extension for improved performance with large files.
TOML won’t replace YAML or NEON everywhere, but it has its place — especially when interoperating with tools that already use it. For PHP projects that need TOML support, this library provides a complete implementation.
Personally, I still reach for NEON or classic PHP arrays in most projects — for deeply nested configs, they’re simply more concise. TOML shines in flatter structures and cross-language tooling. What are you using?
How two plugins work together to bring enterprise-grade data integrity to your CakePHP application
Every application that handles important data eventually faces the same questions:
These aren’t edge cases. They’re fundamental requirements for any serious business application. Yet many frameworks leave you to implement these patterns from scratch, leading to inconsistent solutions scattered across your codebase.
CakePHP developers now have a comprehensive answer: Bouncer and AuditStash — two plugins that, together, provide complete data governance for your application.
AuditStash automatically tracks every change to your data. Every create, update, and delete is logged with:
// In your Table class
public function initialize(array $config): void
{
parent::initialize($config);
$this->addBehavior('AuditStash.AuditLog', [
'blacklist' => ['password', 'token'],
]);
}
That’s it. Every change to this table is now permanently recorded.
Bouncer intercepts changes before they happen. Instead of immediately saving user submissions, it stores them as drafts pending approval:
// In your Table class
public function initialize(array $config): void
{
parent::initialize($config);
$this->addBehavior('Bouncer.Bouncer', [
'actions' => ['add', 'edit', 'delete'],
]);
}
Now when a user saves a record:
Here’s where it gets interesting. These plugins aren’t just useful individually — they’re designed to complement each other.
Consider a financial application where:
// AccountsTable.php
public function initialize(array $config): void
{
parent::initialize($config);
// Track ALL changes for compliance
$this->addBehavior('AuditStash.AuditLog', [
'blacklist' => ['internal_notes'],
]);
// Require approval for modifications
$this->addBehavior('Bouncer.Bouncer', [
'actions' => ['edit', 'delete'],
'bypassCallback' => function ($entity, $options) {
// Auto-approve small changes by senior staff
$user = $options['user'] ?? null;
$isSenior = $user && $user->role === 'senior';
$isSmallChange = abs($entity->balance - $entity->getOriginal('balance')) < 10000;
return $isSenior && $isSmallChange;
},
]);
}
The result:
| Action | Junior Staff | Senior Staff (< $10k) | Senior Staff (>= $10k) |
|---|---|---|---|
| Propose change | Draft created | Auto-approved | Draft created |
| Audit logged | Yes (draft) | Yes (immediate) | Yes (draft + approval) |
| Requires review | Yes | No | Yes |
A publishing platform where:
// ArticlesTable.php
public function initialize(array $config): void
{
parent::initialize($config);
$this->addBehavior('AuditStash.AuditLog');
$this->addBehavior('Bouncer.Bouncer', [
'actions' => ['add', 'edit'],
]);
}
The workflow:
Writer submits article
|
v
[Draft Created] --- AuditStash logs: "draft submitted"
|
v
Editor reviews diff
|
/ \
v v
Approve Reject
| |
v v
Published Writer notified
|
v
AuditStash logs: "approved and published"
If a published article causes problems, AuditStash provides the complete timeline:
// In your controller
$timeline = $this->AuditLogs->find('timeline', [
'source' => 'articles',
'primary_key' => $articleId,
]);
// Returns every change, who made it, when, and what changed
When a user requests data deletion under GDPR:
# Export all audit data for a user
bin/cake audit_stash gdpr export --user 42 --output user-42-data.json
# Anonymize user's audit trail (keeps records, removes PII)
bin/cake audit_stash gdpr anonymize --user 42
# Or completely delete if required
bin/cake audit_stash gdpr delete --user 42
When two users edit the same record simultaneously, Bouncer’s 3-way merge shows exactly what conflicts:
Original: "Product costs $100"
User A: "Product costs $120" (pending)
User B: "Product costs $95" (pending)
Admin sees both proposals side-by-side and decides
Keep audit logs manageable with automatic cleanup:
// config/audit_stash.php
return [
'AuditStash' => [
'retention' => [
'default' => '2 years',
'financial_records' => false, // Never delete
'session_logs' => '30 days',
],
],
];
# Clean up old logs (respects retention policies)
bin/cake audit_stash cleanup --force
Don’t clutter your audit log with noise:
$this->addBehavior('AuditStash.AuditLog', [
'ignoreTimestampOnly' => true, // Skip if only modified changed
'ignoreWhitespace' => true, // Skip whitespace-only edits
'ignoreFields' => ['view_count', 'last_accessed'],
]);
Both plugins ship with complete admin interfaces:
/admin/audit-logs)/admin/bouncer)Both now include self-contained Bootstrap 5 layouts, meaning they work out of the box without requiring your application to use Bootstrap.
Installation is straightforward:
composer require dereuromark/cakephp-audit-stash
composer require dereuromark/cakephp-bouncer
Load the plugins:
// src/Application.php
public function bootstrap(): void
{
parent::bootstrap();
$this->addPlugin('AuditStash');
$this->addPlugin('Bouncer');
}
Run migrations:
bin/cake migrations migrate --plugin AuditStash
bin/cake migrations migrate --plugin Bouncer
Add behaviors to your tables, and you’re protected.
Data governance isn’t optional anymore. Regulations like GDPR, SOX, and HIPAA demand accountability. Users expect undo functionality. Businesses need approval workflows.
AuditStash and Bouncer bring these enterprise patterns to CakePHP with minimal configuration:
Both plugins have reached their 1.0.0 stable releases, ready for production use.
Links:
Zero Reflection, Zero Regrets
Every PHP developer knows the pain. You’re deep in a template, staring at $data['user']['address']['city'], wondering if that key actually exists or if you’re about to trigger a notice that’ll haunt your logs forever.
DTOs solve this. But the cure has often been worse than the disease.
This post aims to:
array > ArrayObject > DTO performance loss
Modern PHP DTO libraries are clever. Too clever. They use runtime reflection to magically hydrate objects from arrays, infer types from docblocks, and validate on the fly. It’s beautiful—until you profile it.
Every. Single. Instantiation. Pays the reflection tax.
For a simple API endpoint returning 100 users? That’s 100 reflection calls. For a batch job processing 10,000 records? You’re burning CPU cycles on introspection instead of actual work.
And then there’s the IDE problem. Magic means your IDE is guessing. “Find Usages” becomes “Find Some Usages, Maybe.” PHPStan needs plugins. Autocomplete works… sometimes.
Here’s a radical idea: what if we did all that reflection once, at build time, and generated plain PHP classes?
Data Transfer Objects (DTOs) have become essential in modern PHP applications. They provide type safety, IDE autocomplete, and make your code more maintainable. But the PHP ecosystem has long debated how to implement them: runtime reflection or manual boilerplate?
php-collective/dto takes a third path: code generation. Define your DTOs once in configuration, generate optimized PHP classes, and enjoy the best of both worlds.
The PHP DTO landscape in 2026 looks like this:
These are excellent tools, but they share a common limitation: runtime reflection overhead. Every time you create a DTO, the library inspects class metadata, parses types, and builds the object dynamically.
What if we did all that work once, at build time?
The idea is not that radical after all. Similar implementations have existed for more than 15 years, way before modern PHP and the new syntax and features it brought along. I have been using it for a bit more than 11 years now myself.
You decide on config as XML, YAML, NEON or PHP. PHP using builders is the most powerful one, as it has full auto-complete/type-hinting:
return Schema::create()
->dto(Dto::create('User')->fields(
Field::int('id')->required(),
Field::string('email')->required(),
Field::dto('address', 'Address'),
))
->toArray();
Run the generator:
vendor/bin/dto generate
Get a real PHP class:
class UserDto extends AbstractDto
{
public function getId(): int { /* ... */ }
public function getEmail(): string { /* ... */ }
public function getAddress(): ?AddressDto { /* ... */ }
public function setEmail(string $email): static { /* ... */ }
// ...
}
No magic. No reflection. Just PHP.
The concept was first used almost 2 decades ago in e-commerce systems that had a high amount of modular packages and basically disallowed all manual array usage. All had to be DTOs for maximum extendability and discoverability. The project could add fields per DTO as needed. The XMLs of each module as well as project extensions were all merged together. XML makes this easy, and the generated DTOs are fully compatible with both core and project level.
I never needed the “merging” feature, but I did like how quickly you could generate them, and that it could always generate full DTOs with all syntactic sugar as per current “language standards”.
Personally I always liked the XML style, because with XSD modern IDEs have full autocomplete and validation on them. But in some cases PHP might be more flexible and powerful.
Choose what works for your team:
XML (with XSD validation):
<dto name="User">
<field name="id" type="int" required="true"/>
<field name="email" type="string" required="true"/>
<field name="roles" type="string[]" collection="true"/>
</dto>
Or use YAML or NEON for minimal syntax. Or stick to the PHP one above.
Mutable (default) – traditional setters:
$user = new UserDto();
$user->setName('John');
$user->setEmail('john@example.com');
Immutable – returns new instances:
$user = new UserDto(['name' => 'John']);
$updated = $user->withEmail('john@example.com');
// $user is unchanged, $updated has new email
Configure per-DTO:
Dto::immutable('Event')->fields(/* ... */);
APIs use snake_case. JavaScript wants camelCase. Forms send dashed-keys. Handle all of them:
// From snake_case database
$dto->fromArray($dbRow, false, UserDto::TYPE_UNDERSCORED);
// To camelCase for JavaScript
return $dto->toArray(); // default camelCase
// To snake_case for Python API
return $dto->toArray(UserDto::TYPE_UNDERSCORED);
<dto name="Order">
<field name="items" type="OrderItem[]" collection="true" singular="item"/>
</dto>
Generated methods:
$order->getItems(); // ArrayObject<OrderItemDto>
$order->addItem($itemDto); // Type-checked
$order->hasItems(); // Collection not empty
Associative collections work too:
$config->addSetting('theme', $settingDto);
$theme = $config->getSetting('theme');
Custom collection factories let you use Laravel Collections, Doctrine ArrayCollection, or CakePHP Collection (when generated with a non-\ArrayObject collection type):
Dto::setCollectionFactory(fn($items) => collect($items));
// Now all getters return Laravel collections
$order->getItems()->filter(...)->sum(...);
$company = new CompanyDto($data);
// Safe nested reading with default
$city = $company->read(['departments', 0, 'address', 'city'], 'Unknown');
// Deep cloning - nested objects are fully cloned
$clone = $company->clone();
$clone->getDepartments()[0]->setName('Changed');
// Original unchanged
Share types with your frontend:
vendor/bin/dto typescript --output=frontend/src/types/
Generates:
export interface UserDto {
id: number;
email: string;
name?: string;
roles: string[];
}
export interface OrderDto {
id: number;
customer: UserDto;
items: OrderItemDto[];
}
Options include multi-file output, readonly interfaces, and strict null handling.
Know exactly what was changed:
$dto = new UserDto();
$dto->setEmail('new@example.com');
$changes = $dto->touchedToArray();
// ['email' => 'new@example.com']
// Perfect for partial database updates
$repository->update($userId, $changes);
Every nullable field gets an OrFail variant:
$email = $dto->getEmail(); // string|null
$email = $dto->getEmailOrFail(); // string (throws if null)
Use after validation to avoid null checks:
$email = $dto->getEmailOrFail(); // PHPStan now knows it is not nullable
Enforce data integrity at creation:
<field name="id" type="int" required="true"/>
new UserDto(['name' => 'John']);
// InvalidArgumentException: Required fields missing: id
Beyond required fields, you can add common validation constraints:
Dto::create('User')->fields(
Field::string('name')->required()->minLength(2)->maxLength(100),
Field::string('email')->required()->pattern('/^[^@]+@[^@]+\.[^@]+$/'),
Field::int('age')->min(0)->max(150),
)
| Rule | Applies To | Description |
|---|---|---|
minLength |
string | Minimum string length |
maxLength |
string | Maximum string length |
min |
int, float | Minimum numeric value |
max |
int, float | Maximum numeric value |
pattern |
string | Regex pattern validation |
Validation runs on instantiation. Null fields skip validation — rules only apply when a value is present.
The validationRules() method extracts all rules as metadata, useful for bridging to framework validators:
$rules = $dto->validationRules();
// ['name' => ['required' => true, 'minLength' => 2, 'maxLength' => 100], ...]
<field name="status" type="\App\Enum\OrderStatus"/>
// From enum instance
$order->setStatus(OrderStatus::Pending);
// From backing value - auto-converted
$order = new OrderDto(['status' => 'confirmed']);
$order->getStatus(); // OrderStatus::Confirmed
<field name="price" type="\Money\Money"/>
<field name="createdAt" type="\DateTimeImmutable"/>
Custom factories for complex instantiation:
Field::class('date', \DateTimeImmutable::class)->factory('createFromFormat')
Apply callables to transform values during hydration or serialization:
Field::string('email')
->transformFrom('App\\Transform\\Email::normalize') // Before hydration
->transformTo('App\\Transform\\Email::mask') // After serialization
Useful for normalizing input (trimming, lowercasing) or masking output (hiding sensitive data). For collections, transforms apply to each element.
Share common fields:
Dto::create('BaseEntity')->fields(
Field::int('id')->required(),
Field::class('createdAt', \DateTimeImmutable::class),
)
Dto::create('User')->extends('BaseEntity')->fields(
Field::string('email')->required(),
)
// UserDto has id, createdAt, and email
Every generated DTO now gets shaped array types on toArray() and createFromArray():
// UserDto with fields: id (int, required), name (string), email (string, required)
/**
* @return array{id: int, name: string|null, email: string}
*/
public function toArray(?string $type = null, ?array $fields = null, bool $touched = false): array
$dto->toArray()['na suggests name
$dto->toArray()['naem'] shows error
['name' => $name] = $dto->toArray() infers $name as string|null
Complement your TypeScript types with JSON Schema for API documentation and contract testing:
vendor/bin/dto jsonschema --output=schemas/
Supports --single-file (with $defs references), --multi-file, --no-refs (inline nested objects), and --date-format options.
mapFrom() and mapTo() — read from email_address in input, write to emailAddr in output
string|int)
@return ArrayObject<int, ItemDto>)
getFullName() from firstName + lastName)
serialize()/unserialize()
--mapper) for SELECT NEW style constructors
class UserController
{
public function show(int $id): JsonResponse
{
$user = $this->repository->find($id);
$dto = UserDto::createFromArray($user->toArray());
// Snake case for JSON API
return new JsonResponse($dto->toArray(UserDto::TYPE_UNDERSCORED));
}
}
public function update(Request $request, int $id): Response
{
$dto = new UserDto();
$dto->fromArray($request->all(), false, UserDto::TYPE_UNDERSCORED);
// Only update fields that were actually submitted
$this->repository->update($id, $dto->touchedToArray());
return new Response('Updated');
}
$event = new OrderPlacedDto([
'eventId' => Uuid::uuid4()->toString(),
'aggregateId' => $orderId,
'occurredAt' => new DateTimeImmutable(),
'order' => $orderDto,
]);
// Create corrected version without mutating original
$corrected = $event->withVersion(2);
We ran comprehensive benchmarks comparing php-collective/dto against plain PHP, spatie/laravel-data, and cuyz/valinor. Test environment: PHP 8.4.17, 10,000 iterations per test.
Versions used: php-collective/dto dev-master (e4e1f9c), spatie/laravel-data 4.19.1, cuyz/valinor 2.3.2. A standalone comparison also includes spatie/data-transfer-object 3.9.1 and symfony/serializer 8.0.5.
| Library | Avg Time | Operations/sec | Relative |
|---|---|---|---|
| Plain PHP readonly DTO | 0.27 µs | 3.64M/s | 2.2x faster |
| php-collective/dto createFromArray() | 0.60 µs | 1.68M/s | baseline |
| spatie/laravel-data from() | 14.77 µs | 67.7K/s | 25x slower |
| cuyz/valinor | 15.78 µs | 63.4K/s | 26x slower |
Standalone benchmarks (using spatie/data-transfer-object instead of laravel-data, which requires a full Laravel app) show 52.8K/s and symfony/serializer 106K/s.
| Library | Avg Time | Operations/sec | Relative |
|---|---|---|---|
| Plain PHP nested DTOs | 1.75 µs | 571K/s | 1.8x faster |
| php-collective/dto | 3.10 µs | 322K/s | baseline |
| spatie/laravel-data | 48.83 µs | 20.5K/s | 16x slower |
| cuyz/valinor | 68.67 µs | 14.6K/s | 22x slower |
Standalone nested results: spatie/data-transfer-object 10.6K/s, symfony/serializer 13.6K/s.
The gap widens with complexity. Runtime libraries pay reflection costs for every nested object. Generated code doesn’t.
| Library | Avg Time | Operations/sec | Relative |
|---|---|---|---|
| Plain PHP toArray() | 0.68 µs | 1.48M/s | 1.8x faster |
| php-collective/dto | 1.20 µs | 832K/s | baseline |
| spatie/laravel-data | 26.95 µs | 37.1K/s | 22x slower |
| Approach | Avg Time | Operations/sec |
|---|---|---|
| Plain PHP property access | 0.11 µs | 9.48M/s |
| php-collective/dto getters | 0.20 µs | 4.91M/s |
| Plain array access | 0.15 µs | 6.77M/s |
Getter methods are nearly as fast as direct property access – the small overhead is negligible in real applications.
| Operation | Avg Time | Operations/sec |
|---|---|---|
| Mutable: setName() | 0.08 µs | 13.1M/s |
| Immutable: withName() | 0.12 µs | 8.34M/s |
Immutable operations are ~1.6x slower due to object cloning, but still extremely fast at 8.3 million operations per second.
| Approach | Avg Time | Operations/sec |
|---|---|---|
| Plain array -> JSON | 1.13 µs | 888K/s |
| Plain PHP DTO -> JSON | 2.07 µs | 484K/s |
| php-collective/dto -> JSON | 2.95 µs | 339K/s |
At 339K JSON documents per second, this is more than sufficient for any web application. A typical API handles 1K-10K requests/second.
Simple DTO Creation (ops/sec, higher is better):
┌──────────────────────────────────────────────────────────────────┐
│ Plain PHP ████████████████████████████████████ 3.64M/s │
│ php-collective ██████████████████ 1.68M/s │
│ laravel-data █ 67.7K/s │
│ valinor █ 63.4K/s │
└──────────────────────────────────────────────────────────────────┘
Complex Nested DTO (ops/sec, higher is better):
┌──────────────────────────────────────────────────────────────────┐
│ Plain PHP ██████████████████████████████████ 571K/s │
│ php-collective ███████████████████ 322K/s │
│ laravel-data ████ 20.5K/s │
│ valinor ███ 14.6K/s │
└──────────────────────────────────────────────────────────────────┘
toArrayFast() avoids per-field metadata lookups
Choose php-collective/dto when:
Consider alternatives when:
php-collective/dto brings the best of code generation to PHP DTOs:
| Aspect | php-collective/dto | Runtime Libraries |
|---|---|---|
| Performance | 25-26x faster | Baseline |
| IDE Support | Excellent | Good |
| Static Analysis | Native | Requires plugins |
| Code Review | Visible generated code | Magic/runtime |
| Build Step | Required | None |
The library is framework-agnostic, well-documented, and actively maintained.
For many apps the performance overhead of reflection might not be relevant. After all, you might only have a few DTOs per template for simpler actions. But in the case that you are handling a huge amount of DTOs, a less magic way could be a viable option. At least it will be more efficient than trying to nano-optimize on other parts of the application.
Adopting DTOs doesn’t have to be a big-bang rewrite. Here’s a practical, incremental path from raw arrays to fully typed DTOs — each step delivers value on its own.
This is where most legacy PHP projects start. Data flows as associative arrays, and every access is a leap of faith:
// Controller
public function view(int $id): Response
{
$user = $this->Users->get($id, contain: ['Addresses', 'Roles']);
$data = $user->toArray();
// Pass array to service
$summary = $this->buildSummary($data);
return $this->response->withJson($summary);
}
private function buildSummary(array $data): array
{
return [
'full_name' => $data['first_name'] . ' ' . $data['last_name'],
'city' => $data['address']['city'] ?? 'Unknown', // exists?
'role_count' => count($data['roles'] ?? []), // array?
];
}
Problems: no autocomplete, no type safety, no way to know the shape without reading the query. A typo like $data['adress'] silently returns null.
Start where it hurts most — the API response layer. Replace outgoing arrays with DTOs:
// Define the DTO config
Dto::create('UserSummary')->fields(
Field::string('fullName')->required(),
Field::string('city'),
Field::int('roleCount'),
);
vendor/bin/dto generate
// Controller — only the return type changes
public function view(int $id): Response
{
$user = $this->Users->get($id, contain: ['Addresses', 'Roles']);
$summary = new UserSummaryDto([
'fullName' => $user->first_name . ' ' . $user->last_name,
'city' => $user->address?->city,
'roleCount' => count($user->roles),
]);
return $this->response->withJson($summary->toArray());
}
The entity query stays the same. The service layer stays the same. But the API contract is now explicit, typed, and autocomplete-friendly. If someone removes city from the DTO config, the generator catches it.
Once boundaries are typed, push DTOs into service signatures:
// Before: what does this array contain? Who knows.
public function calculateShipping(array $order): float
// After: explicit contract
public function calculateShipping(OrderDto $order): float
{
$weight = $order->getItems()
->filter(fn(OrderItemDto $item) => $item->getWeight() > 0)
->sum(fn(OrderItemDto $item) => $item->getWeight());
return $this->rateCalculator->forWeight($weight, $order->getAddress());
}
Every caller now gets a compile-time check (via PHPStan) that they’re passing the right data. The method signature is the documentation.
Target the most common pattern — methods that return arrays of mixed data:
// Before
public function getStats(): array
{
return [
'total_users' => $this->Users->find()->count(),
'active_today' => $this->Users->find('activeToday')->count(),
'revenue' => $this->Orders->find()->sumOf('total'),
];
}
// Template: $stats['total_users'] — typo-prone, no autocomplete
// After
public function getStats(): DashboardStatsDto
{
return new DashboardStatsDto([
'totalUsers' => $this->Users->find()->count(),
'activeToday' => $this->Users->find('activeToday')->count(),
'revenue' => $this->Orders->find()->sumOf('total'),
]);
}
// Template: $stats->getTotalUsers() — autocomplete, type-checked
For CakePHP 5.3+, skip the entity entirely on read paths:
// Before: full entity hydration, then manual mapping
$users = $this->Users->find()
->select(['id', 'email', 'name', 'created'])
->contain(['Roles'])
->all()
->toArray();
// After: straight to DTO, no entity in between
$users = $this->Users->find()
->select(['id', 'email', 'name', 'created'])
->contain(['Roles'])
->projectAs(UserListDto::class)
->all()
->toArray();
The query result maps directly into UserListDto objects. No entity overhead, no intermediate array step.
Not everything needs a DTO. Prioritize based on pain:
| Priority | Where | Why |
|---|---|---|
| High | API responses | External contract, most likely to break silently |
| High | Service method params | Most frequent source of “what keys does this array have?” |
| Medium | Template variables | Autocomplete in templates reduces bugs |
| Medium | Queue/event payloads | Serialization boundaries need explicit shapes |
| Low | Internal helper returns | If only one caller exists, the overhead isn’t worth it |
| Skip | Simple key-value configs | Arrays are fine for ['timeout' => 30] |
UserDto::createFromArray($entity->toArray()) as a bridge during migration — no need to refactor the query layer first.
A live demo is available in the sandbox. Especially check out the “projection” examples that map the DB content 1:1 into speaking DTOs. The needed DTOs can be (re-)generated from the backend with a single click from the DB structure if needed.
Generated code is boring. Predictable. Fast.
Sometimes boring is exactly what you need.
php-collective/dto is available on Packagist. MIT licensed. PRs welcome.