If you’ve used CakePHP for any length of time, you’ve probably shipped apps with
friendsofcake/bootstrap-ui —
the plugin that makes $this->Form->control() output Bootstrap markup without
a single hand-written class. It’s been a workhorse for years.
But Bootstrap isn’t the only game in town anymore. Over the last couple of years Tailwind CSS has gone from “another utility framework” to the default choice for new projects.
So I built cakephp-tailwind-ui.
Install it, load it, use your existing FormHelper calls:
composer require dereuromark/cakephp-tailwind-ui
bin/cake plugin load TailwindUi
In AppView:
use TailwindUi\View\UiViewTrait;
class AppView extends View
{
use UiViewTrait;
public function initialize(): void
{
parent::initialize();
$this->initializeUi();
}
}
That’s it. Every call you already have — $this->Form->control(),
$this->Paginator->links(), $this->Flash->render(), $this->Html->badge() —
now outputs DaisyUI-styled markup. Your templates don’t change.
The easy answer is “because the old one targets the wrong framework.” The real answer is that I wanted to test an idea: what if the class names weren’t hardcoded anywhere?
Bootstrap-ui has form-control and btn btn-primary baked into its templates,
helpers, and widgets. If you want anything else — DaisyUI, KTUI, Flowbite, your
company’s design system — you fork. Every time. And re-fork on every bootstrap-ui
update.
TailwindUi takes a different approach: there’s exactly one map between semantic names and CSS classes, and you can replace it at runtime.
// config/bootstrap.php
Configure::write('TailwindUi.classMap', 'daisyui'); // default, can be omitted
That’s it. Want KTUI (Metronic)?
Configure::write('TailwindUi.classMap', 'ktui');
Want to override just one key without copying the rest?
Configure::write('TailwindUi.classMap', [
'form.input' => 'input input-bordered input-lg w-full',
'btn.primary' => 'btn-primary shadow-lg',
]);
Or use a preset plus overrides:
Configure::write('TailwindUi.classMap', 'ktui');
Configure::write('TailwindUi.classMapOverrides', [
'btn.primary' => 'kt-btn-primary kt-btn-lg',
]);
The plugin ships two presets (daisyui and ktui) and accepts any PHP file in
config/class_maps/ as a custom preset. Want a shadcn preset for your app?
Write the 20 keys that differ. Everything else falls through to the DaisyUI
defaults.
When you drop TailwindUi in for bootstrap-ui, most things produce visually equivalent output. But there are a few spots where TailwindUi is measurably cleaner, and I think they’re worth calling out.
Here’s what bootstrap-ui renders for a multiCheckbox with
'nestedCheckboxAndRadio' => false:
<div class="form-check">
<input type="checkbox" name="tags[]" value="1" class="form-check-input" id="tags-1">
<label class="form-check-label" for="tags-1">PHP</label>
</div>
Notice: the input and label are siblings. The label uses for="tags-1" to
associate. Clicking the label text works — barely — because of browser
accessibility fallbacks, not because the markup is clean.
TailwindUi renders the same field this way:
<div class="mt-1 [&>label]:inline-flex [&>label]:items-center [&>label]:gap-2 [&>label]:cursor-pointer">
<label for="tags-1">
<input type="checkbox" name="tags[]" value="1" class="checkbox" id="tags-1">
PHP
</label>
</div>
The input is inside the label. No for attribute needed. The whole label
plus text is a single click target. The flex layout is handled by a Tailwind
arbitrary variant on the wrapper, so the template stays generic and every
preset can override it.
This is the behavior CakePHP’s nestedCheckboxAndRadio=true default was
designed for — bootstrap-ui just had to fight it because Bootstrap’s
form-check component wants siblings. Tailwind has no such constraint.
Say you want every btn-primary in your app to have a subtle shadow.
Bootstrap-ui path:
BootstrapUI\View\Widget\ButtonWidget
FormHelper
render() to inject shadow-sm
TailwindUi path:
Configure::write('TailwindUi.classMapOverrides', [
'btn.primary' => 'btn-primary shadow-sm',
]);
One line. Every $this->Form->submit('Save', ['class' => 'primary']),
$this->Html->link('Save', $url, ['class' => 'btn primary']), and
$this->Html->button('Save', ['class' => 'primary']) now emits the
shadow.
Bootstrap-ui ships Bootstrap’s full JS bundle (Popper.js + bundle, ~80KB minified) because its alerts, modals, and dropdowns need it. The default layout template loads them.
TailwindUi’s default layout loads DaisyUI CSS and nothing else. Dismiss buttons on flash alerts use a 40-byte inline handler:
<button onclick="this.closest('[role=alert]').remove()">×</button>
If you want fancier interactions (modals, dropdowns, tooltips), you layer on Alpine.js or HTMX or whatever you prefer. The plugin doesn’t decide for you.
This is the one that surprised me. Because the class resolution happens on first-use of each helper, you can actually swap presets per-request:
// Dark theme for admin area, default for front-end
if ($this->request->getParam('prefix') === 'Admin') {
Configure::write('TailwindUi.classMap', 'ktui');
}
The same templates/Articles/index.php file produces two completely
different visual outputs. No template duplication, no controller logic in
views.
The full map is ~60 keys in config/class_maps/daisyui.php. A taste:
return [
// Form inputs
'form.input' => 'input input-bordered w-full',
'form.select' => 'select select-bordered w-full',
'form.textarea' => 'textarea textarea-bordered w-full',
'form.checkbox' => 'checkbox',
'form.radio' => 'radio',
'form.switch' => 'toggle',
'form.label' => 'label-text',
'form.helpText' => 'label-text-alt text-base-content/60',
'form.error' => 'text-error text-sm mt-1',
'form.inputError' => 'input-error',
'form.container' => 'mb-4',
'form.containerHorizontal'=> 'flex items-start gap-4 mb-4',
'form.labelHorizontal' => 'w-48 pt-2 shrink-0 text-right',
// Buttons
'btn' => 'btn',
'btn.primary' => 'btn-primary',
'btn.danger' => 'btn-error',
'btn.outline' => 'btn-outline',
// ...
// Pagination, alerts, breadcrumbs, cards, tables, icons...
];
Every helper reads from this map. When you call
$this->Form->control('title'), the FormHelper asks for form.input and
form.container and composes the right HTML. When you call
$this->Html->badge('Active', ['class' => 'success']), the HtmlHelper strips
success, reads badge.success, and emits badge badge-success.
That’s the entire design. There’s no inheritance hierarchy, no service locator, no factory pattern. It’s a config array.
The plugin was actually born out of a Metronic v9 project. Metronic ships with
KTUI — Keenthemes’ Tailwind component library — which uses classes like
kt-input, kt-btn kt-btn-primary, kt-card, kt-badge kt-badge-success.
Completely different naming from DaisyUI, same idea.
To use it, set one config key:
Configure::write('TailwindUi.classMap', 'ktui');
The plugin then emits <input class="kt-input">, <button class="kt-btn kt-btn-primary">,
etc. The helper API, your templates, your controllers — all unchanged.
There’s one honest caveat: KTUI isn’t on a CDN and isn’t purely a CSS file.
Metronic’s styles.css is Tailwind v4 tree-shaken against Metronic’s own
source files, so classes Metronic didn’t use (like bg-yellow-50 or arbitrary
variants) aren’t in the compiled bundle. If you’re building a real KTUI app,
you’d compile Tailwind v4 yourself against your own template sources and
everything the plugin emits gets picked up.
The plugin ships a ktui layout (templates/layout/ktui.php) that
initializeUi() auto-selects when the KTUI preset is active, so you don’t
have to juggle that manually.
Because this is a FriendsOfCake-style plugin, it has a bake theme:
bin/cake bake template Articles --theme TailwindUi
This generates index.php, view.php, add.php, and edit.php with
DaisyUI-styled cards, zebra tables, pagination, and action buttons. The
templates use the plugin’s helpers, which means they automatically work with
any preset. Bake once, swap presets, different output.
Here’s an article CRUD form after installing the plugin. No manual classes:
<?= $this->Form->create($article, ['align' => 'horizontal']) ?>
<?= $this->Form->control('title') ?>
<?= $this->Form->control('user_id', ['options' => $users]) ?>
<?= $this->Form->control('body', ['type' => 'textarea', 'rows' => 8]) ?>
<?= $this->Form->control('tags._ids', ['multiple' => 'checkbox', 'options' => $tags]) ?>
<?= $this->Form->control('published_date', ['type' => 'date']) ?>
<?= $this->Form->control('price', ['prepend' => '$', 'help' => 'USD']) ?>
<?= $this->Form->control('active', ['type' => 'checkbox', 'switch' => true]) ?>
<?= $this->Form->submit('Save Article', ['class' => 'primary']) ?>
<?= $this->Form->end() ?>
Every single control gets the right Tailwind/DaisyUI classes. The horizontal
alignment uses flex. The switch checkbox is a DaisyUI toggle. The input group
shows a $ prepended. The help text gets an aria-describedby link. Tags
render as a multi-checkbox group with the correct _ids wiring.
Same template, swap to KTUI preset, every input becomes kt-input, every
button becomes kt-btn kt-btn-primary, every checkbox becomes
kt-checkbox. The template file never changes.
The plugin is a ~90% drop-in replacement for bootstrap-ui. The 10% is captured in issue #1:
align => 'inline')
staticControl() method
If any of them are blockers for you, open an issue or PR.
For typical admin panels, CRUD pages, and forms — the 85% covers everything you actually use every day.
composer require dereuromark/cakephp-tailwind-ui
bin/cake plugin load TailwindUi
Screenshots (DaisyUI left, KTUI right for each component) in the docs/ folder.
Repository: github.com/dereuromark/cakephp-tailwind-ui
Feedback, bug reports, and preset contributions (Flowbite, Preline, shadcn, your own design system) are very welcome.
Every Shopware shop hits the same wall eventually. You need rich text somewhere the
core doesn’t give you a WYSIWYG – a category landing blurb, a brand story on the
manufacturer, a formatted paragraph inside a shipping mail – and you’re left with two
bad options. Paste raw HTML into a text field and pray nobody breaks the markup (or
worse, injects a <script>). Or bolt on yet another WYSIWYG editor that emits soupy,
inconsistent HTML that looks different in every channel it lands in.
shopware-carve takes a different route. You author in Carve – a compact, readable plain-text markup – and the plugin renders it to safe, semantic HTML everywhere it’s needed. One source. Ten surfaces. No bolt-on sanitizer.
New to Carve? Start with the origin story, Twenty years of Markdown hindsight, in one markup language, which covers what the format is and why it exists. This post is about putting it to work in Shopware 6.
Content in a shop is not one thing. The same paragraph might need to appear as:
<meta> description,With HTML-in-a-textarea, each of those is a separate manual chore, and each is a fresh chance to ship broken or unsafe output. Markdown helps a little, but it has no safe, first-class way to embed a live product reference, no admonitions, no tables worth the name, and its “just allow some HTML” escape hatch is exactly the XSS hole you were trying to avoid.
Carve is built for this job specifically: one authored source, many render targets, with the safety baked into the renderer instead of duct-taped on afterward. How Carve closes the unsafe-HTML hole is next in Safe by default – not safe-if-you-remember-to.
This is the part worth reading twice. The hardening is always on and independent of any plugin setting:
Because that hardening cannot be switched off, the |carve Twig filter is registered
as is_safe => html – Twig won’t double-escape it, and it doesn’t need a separate
sanitizer pass. Raw HTML passthrough is off by default; you’d have to explicitly opt
in (ShopwareCarve.config.allowRawHtml), and only for fully trusted authors.
For untrusted input there are content profiles that clamp what’s even renderable:
| Profile | What it allows |
|---|---|
minimal | Inline formatting + paragraphs and lists only. Great for chat/micro-posts. |
comment | Basic inline + lists/quotes/code. No headings, images, tables, raw HTML. |
article | Everything except raw HTML. For trusted blog/article authors. |
none | No restriction (default for trusted admin content). |
Anything a profile disallows degrades to inert text rather than throwing. A customer
who pastes a <script> into a review gets their harmless words back – never an
execution.
Here’s the whole surface area the plugin exposes, from the primitive everything is built on up to the edge cases:
| Surface | What you get |
|---|---|
|carve Twig filters | Safe HTML, plain text, or Markdown from any template. The universal primitive. |
| Carve CMS element | A drag-and-drop safe rich-text block in Shopping Experiences. |
Product field carve_body | Structured, diffable product copy under the description. |
| Category field | Rich landing copy at the top of a category listing. |
| Manufacturer field | Authored brand story on the manufacturer entity. |
| Admin live preview | Byte-identical WYSIWYG powered by carve-js, no API roundtrip. |
| Transactional mail | One source feeds both the HTML and plain-text mail parts. |
Inline :product[SKU] | A live product link with name and price, resolved at render time. |
| Product reviews (UGC) | Customer text hardened by the comment profile. |
CLI carve:render | Render a .crv file to HTML / Markdown / plain / ANSI. |
The magic isn’t any single surface – it’s that they all share one renderer and one syntax. The product blurb you write is the same Carve you’d drop into a CMS block, the same you’d pipe through the CLI, the same the admin previews live as you type.
The admin live preview runs carve-js; the storefront runs carve-php. They share a
cross-implementation test corpus that guarantees the same source produces the same
bytes. That means the preview you see while editing is not an approximation – it’s the
storefront output. WYSIWYG you can actually trust.
Carve reads like clean plain text. A product description might be:
# Ethiopia Yirgacheffe --- Washed
A bright, floral single-origin, grown at 1,900--2,100 m.
::: tip "Brew it bright"
Keep the water just off the boil (94 °C) and don't over-extract.
:::
|= Attribute |=> Value |
| Origin | Gedeo, Ethiopia |
| Process | Washed |
Pairs perfectly with our :product[CARVE-B] for a consistent grind.
Note the details that Markdown can’t do cleanly: the ::: tip admonition, a pipe
table with per-column alignment, ---/-- becoming proper em/en dashes, and
:product[CARVE-B] resolving to a live, in-stock product link against the current
sales channel. Unknown or out-of-stock SKUs quietly degrade to plain text – no
exceptions, no broken page.
And because the source is plain text, it’s:
The moment most markup formats hit a “I just need a <span class> here” wall, they tell
you to drop into raw HTML – and now you own an XSS surface again. Carve’s answer is
attributes: you attach ids, classes, and key/values to any element and the renderer
emits the semantic tag for you. No angle brackets, nothing to sanitize.
Ships in [3-5 days]{.badge .badge-info}, backordered items in [~3 weeks]{.badge}.
{#returns .policy role=note}
::: note "30-day returns"
Unworn, in original packaging. See the [full policy](/returns).
:::
That inline […]{.badge} becomes <span class="badge badge-info">; the block
attributes become <div id="returns" class="note policy" role="note">. Your theme’s
CSS targets .badge and .policy directly – the author never touches markup, and the
output stays on the safe side of the renderer.
Three attribute tricks that pull their weight in a shop:
{#sizing} above a heading and link it anywhere with
</#sizing> – the link text auto-fills from the heading, so it never goes stale when
you rename the section. Great for long size-guide or FAQ pages.::: promo fence renders <div class="promo">. Wrap a
call-to-action or a legal block, style it per-theme, skip the raw <div>.^ Figure 1: ... line under an image or table emits
a proper <figure>/<figcaption> and is itself referenceable by </#fig-1>.The rule of thumb: if you were reaching for raw HTML to add a hook or a landmark, reach for an attribute instead. Same output, none of the risk.
carve_body field to a junior author or a customer
and the worst case is ugly text, not a compromised checkout.|carve for HTML, |carve_text for the plain-text mail part or a
meta description, |carve_md for an export feed – one source, three targets.:product[SKU] is the thing Markdown fundamentally
can’t offer: authored copy with real, resolved commerce entities in it..crv still renders.This is where it gets fun. Some are shipped surfaces (all of One source, ten surfaces); others are patterns you can build on top in an afternoon.
carve_body and let the
core description stay a short summary. Buyers get a real story; merchandisers get a
diffable field.note callout, a bulleted value prop – without touching a theme file.renderReviews and customer feedback gets basic
formatting (bold, italic, links, lists) through the hardened comment profile –
headings, images, and <script> all degrade to inert text.code-group, tabs, admonitions,
and tables make it a legitimate lightweight docs engine for setup guides or FAQs –
no separate CMS.:product[SKU] lets you
add :price[SKU], :badge[new], :stock[SKU], or a legal-snippet include that
resolves against live data at render time.For theme and plugin devs, the filters are globally registered – no wiring needed:
{{ myEntity.translated.someCarveField | carve }}
{{ body | carve_text }} {# same source, plain-text channel #}
{{ content | carve_ctx(context) }} {# resolves :product[SKU] refs #}
That carve_ctx variant is the one to reach for whenever content might contain a
product reference – it passes the sales-channel context so links resolve correctly.1
|carve_ctx(context) when there might be. The latter is a strict superset in behavior.
There are also honest limits worth knowing: Shopware core has no native storefront
manufacturer page, so that field is filter-only – a theme decides where it renders.
And Mermaid/chart rendering lazy-loads a library from a CDN, so you’ll need to add
cdn.jsdelivr.net to your CSP if you enable it. None of this is hidden; it’s all in
the docs.
The extension and “live” usage is only one of many possibilities enabled. The format is
the asset – the plugin is just one consumer of it. Once your copy lives in .crv, the
rest of the ecosystem comes for free:
If you’ve ever maintained the same paragraph in multiple places, or shipped a text field that turned out to be an XSS hole, or fought a WYSIWYG that serialized differently on Tuesdays – shopware-carve is the boring, correct fix. Author once, in plain text. Render safely, everywhere.
One source, ten surfaces. That’s the whole idea – and it holds up.
Get started
Install it: composer require markup-carve/shopware-carve. Full surface tour with
screenshots lives in the repo’s GALLERY.md.
Markdown won because it disappears: you write text, and the markup stays out of your way. But anyone who has used it for real documents knows where it stops disappearing – the moment you need a table with a merged cell, a numbered figure, a citation, or just the string snake_case without a stray italic. Then you are suddenly writing HTML inside your “plain text”.
Carve is a lightweight markup language built on a simple premise: keep everything Markdown got right, and fix what twenty years of daily use exposed. Here is what that looks like in practice.
If you are interested in some of the history, there is a chronological survey of lightweight markup languages – from AsciiDoc onward – showing where some ideas came from and how it all came together in a single, coherent syntax.
Carve’s inline delimiters are visual mnemonics – each one hints at its own effect:
*bold* looks bold. /italic/ slants. _underline_ underlines.
~strike~ strikes through. =highlight= marks.
One character per style, no doubling. Compare remembering whether **, __, * or _ gives you bold in which Markdown flavor.
Every Carve delimiter obeys one uniform word-boundary rule: a delimiter inside a word never starts formatting. So all of these are plain text, no escaping:
snake_case a/b/c x = 5 key=value $1,000 50% me@example.com
If you want intraword emphasis, you say so explicitly: x{*y*}z. Explicit beats accidental.
Some characters are too common in ordinary prose or within text itself (no whitespace). Carve gives them the same one-char-one-effect treatment, just bounded in {} by default so they only mean markup and can be safely used within text:
{^super^} raises. {,sub,} drops. {+inserted+} adds. {-deleted-} removes.
{~old~>new~} replaces. {#a note#} annotates.
The braces are a boundary, not a second language – the character inside is still the mnemonic, and it is still one character, no doubling. What that buys you is the most universal mnemonics of all: + and - are the plus and minus of every diff, changelog and code review you have read – green added, red removed – now first-class inline edits (<ins> and <del>), not a bolt-on.
Markdown’s ambiguities are famous: four spaces might be a code block or a nested list, emphasis nesting depends on delimiter runs and flanking rules, and every parser resolves the edge cases differently. Carve’s design principle is that every construct has exactly one interpretation, and what a line means never depends on a line that comes later. Fewer surprises, and identical output everywhere (more on the “everywhere” below).
Two examples. First, list markers never interrupt a paragraph – a list always needs a blank line before it. A sentence continuing on a line that happens to start with 2. stays a sentence, instead of silently becoming an ordered list (CommonMark needs a special “only if it starts with 1.” heuristic here; Carve needs no heuristic at all).
Second, remember “four spaces might be a code block or a nested list”? Carve removes the guessing game entirely: to put another block inside a list item, you do not count indent spaces – a lone + attaches the next flush-left block to the item:
- step one
+
```bash
make install
```
The same + works after a blockquote, so a list or code block can live inside a quote without prefixing every line with >.
Header cells, per-column and per-cell alignment, multi-line cells, and – the big one – rowspan and colspan:
|= Category |= Item |= Price |
| Fruit | Apple | $1 |
| ^ | Banana | $0.50 |
| Total | < | $1.50 |
The ^ merges upward, the < merges left. Your existing GFM tables with |---| separator rows also just work.
Small things you stopped noticing you were fighting:
\ at the end of a line forces a break – instead of Markdown’s two trailing spaces, the only significant whitespace in computing that your editor is configured to delete on save.%% starts a line or trailing comment, %%% fences a block comment. No more abusing <!-- --> and hoping your renderer does not pass it through.--- block is defined behavior (with ---toml and ---json variants), not a convention that every static site generator reinvents slightly differently.Everything you normally bolt on with HTML, LaTeX or preprocessors:
[^note]) and inline (^[like this])[@knuth84])</#setup> renders a link that adopts the heading text automatically – rename the heading, the link text follows^ Figure #: A sunset under an image and the # becomes the next number; cross-references then resolve to “Figure 1”::: note), definition lists, verse blocks, math, glossary and index generation, a table-of-contents directive{.class #id key=value}And no raw HTML by default – which is not a limitation but a feature, because:
Carve’s specification makes hardening normative: URL scheme denylists, attribute sanitization, Trojan-Source/bidi-character stripping, and guaranteed linear-time parsing with bounded nesting are spec rules with pinned conformance tests. A conformant Carve renderer cannot be trivially XSS-ed or DoS-ed. Markdown leaves all of that to each implementation’s discretion.
And when you genuinely need raw output, you say so explicitly, per format: a ```=html fence (or inline `<br>`{=html}) passes through only when rendering to HTML. An opt-in escape hatch you can grep for, not a default backdoor.
This is the quiet superpower. Carve ships three reference implementations – JavaScript, PHP and Rust – that produce byte-identical HTML, verified against a conformance corpus of 388 pinned input/output pairs on every commit. Bindings exist for Python, Ruby (with direct PDF output), Go and WebAssembly. If you have ever debugged why your Markdown preview, your CI renderer and your site generator disagree about the same file, you know why this matters.
markdownToCarve converts existing documents. Renderers exist for HTML, Markdown, plain text and ANSI terminals, plus a formatter (carve fmt), a linter, and a full AST API. Editor support covers VS Code, JetBrains, Vim, Emacs, Sublime, Zed and Helix; integrations exist for WordPress, Hugo, Jekyll, Eleventy, Astro, MkDocs, Symfony and Shopware.
The playground runs in your browser. The comparison page puts Carve side by side with Markdown, djot and MDX, construct by construct.
Carve is young (spec 0.1) and honest about it. But the foundations – one meaning per construct, byte-identical implementations, security in the spec – are exactly the things you cannot retrofit later. That is the lesson of the last twenty years, and it is built in from day one.
Contributions, ports and criticism welcome. Also looking for co-maintainers.
And don’t forget to take a look at the awesome-carve list for an ecosystem overview.
Note: This blog post is dogfooded. It was written and rendered with WP Carve plugin and Carve language, incl. the auto-generated TOC on the right side. And so are any comments added by visitors.
CakePHP 5.4.0-RC2 just dropped. The final 5.4.0 will follow shortly, so now is the time to pull the release candidate into your apps and surface any regressions while they are still cheap to fix.
This post highlights the changes that are most likely to show up in day-to-day code, with small, runnable examples. The full diff between 5.3 and 5.next is roughly 100 commits — what follows is the cream, not the full list.
composer require cakephp/cakephp:5.4.0-RC2 --update-with-dependencies
If you maintain plugins, also try your plugin’s test suite against the RC — that is where most surprises tend to live.
The default loading strategy for HasMany and BelongsToMany associations changed from select to subquery. For most apps this is a net win on larger result sets — it avoids the N+1 multi-IN(...) query that grew unbounded with page size.
If a specific association regresses for you (e.g. very small parent sets, or strange index plans on your DB), opt back in explicitly:
$this->hasMany('Comments')
->setStrategy('select');
The behavior is identical, only the default flipped. Worth re-running your slowest endpoints and comparing query plans.
Second behavior change worth retesting: Model.afterSaveCommit and Model.afterDeleteCommit are now correctly dispatched when save() or delete() runs inside an outer transaction. Previously these events were either suppressed or fired at the wrong moment in nested-transaction scenarios — a long-standing footgun for anyone using event listeners for audit logs, search-index updates, notifications, or other post-commit side effects.
If you have code like this:
$this->Articles->getEventManager()->on(
'Model.afterSaveCommit',
fn($event, $article) => $this->Search->index($article),
);
$connection->transactional(function () {
$article = $this->Articles->save($newArticle);
$this->Comments->save($firstComment);
// afterSaveCommit for $article was previously NOT fired here in 5.3
});
…the search index now updates exactly once, after the outer transaction commits. Rolled-back transactions correctly discard the pending callbacks, and they don’t leak between transactions.
The underlying machinery is also exposed publicly as Connection::afterCommit() if you want to defer arbitrary post-commit work yourself:
$connection->afterCommit(function () {
$this->mailer->sendWelcome($user);
});
// fires after the outermost transaction commits;
// runs immediately if no transaction is active.
This is the kind of fix that quietly removes a class of bugs — but it also means any listener you previously expected never to fire in those nested-save paths will now actually run. Re-check your event listeners.
Table::delete($entity), save(), patchEntity(), and loadInto() previously accepted any EntityInterface. A copy-paste bug like $this->Invoices->delete($orderEntity) would happily delete from the wrong table — silent data loss, no exception.
5.4 (#19428) adds a runtime assertion at the persistence and marshalling chokepoints. If the entity’s concrete class doesn’t match the table’s configured entity class, you get an InvalidArgumentException instead:
$order = $this->Orders->get(1);
$this->Invoices->delete($order);
// InvalidArgumentException: Entity of class App\Model\Entity\Order
// does not match table entity class App\Model\Entity\Invoice
The escape hatch is the generic Cake\ORM\Entity itself, so ad-hoc usage like $table->delete(new Entity(['id' => 1])) still works. Only Entity subclasses that belong to a different table get rejected, which is the actual bug we want to catch.
The check is wired into:
save(), saveOrFail(), saveMany(), saveManyOrFail()
delete(), deleteOrFail(), deleteMany(), deleteManyOrFail()
patchEntity(), patchEntities()
loadInto()
This is a behavior change in a minor: code that previously silently mis-fired will now throw. Worth grepping your codebase for cross-table delete() / save() calls before deploying the RC.
cakephp-ide-helper 2.18.0 ships a matching 'strict' tier for the concreteEntitiesInParam option, mirroring the runtime guard at the PHPStan level. After upgrading and re-running bin/cake annotate all, table classes get explicit @method doc-blocks typed with their concrete entity:
/**
* @method \App\Model\Entity\Invoice|false delete(\App\Model\Entity\Invoice $entity, array $options = [])
* @method \App\Model\Entity\Invoice deleteOrFail(\App\Model\Entity\Invoice $entity, array $options = [])
* @method iterable<\App\Model\Entity\Invoice> deleteMany(iterable<\App\Model\Entity\Invoice> $entities, array $options = [])
* @method \App\Model\Entity\Invoice loadInto(\App\Model\Entity\Invoice $entity, array $contain)
*/
class InvoicesTable extends Table
PHPStan now rejects $this->Invoices->delete($order) before the code ever runs. Same bug, two layers of defense. The static one fails the build, not the request.
Opt in via config/app.php (or wherever you load your IdeHelper config):
'IdeHelper' => [
'concreteEntitiesInParam' => 'strict',
],
The 'strict' tier also narrows iterable params on patchEntities / saveMany / deleteMany to iterable<TEntity> even if you leave genericsInParam at the default false.
SelectQuery->disableHydration() always returned arrays at runtime, but the static type still resolved to entity|array — so every array access needed a cast or a PHPStan ignore. Worse, disableHydration() becomes a hard error in 6.0.
5.4 adds Table::unhydratedFind() returning a dedicated UnhydratedSelectQuery. It behaves exactly like a normal select query (eager loading, finders, the lot), but because it extends SelectQuery<array<string, mixed>>, first() / all() / toArray() / iteration resolve to arrays at the type level:
$rows = $this->Articles->unhydratedFind()
->where(['published' => true])
->all();
foreach ($rows as $row) {
echo $row['title']; // typed as array, no cast, no PHPStan ignore
}
Use it anywhere you read raw rows for performance or export and never needed entities. It is the forward-compatible replacement for disableHydration().
A small marshalling fix that removes a common validation papercut: a datetime column fed a date-only Y-m-d string (e.g. from a <input type="date">) used to marshal as invalid. 5.4 accepts it and sets the time to midnight:
// posting 'starts_at' => '2026-06-28' to a datetime field
$event = $this->Events->newEntity($data);
$event->starts_at; // 2026-06-28 00:00:00 — no longer an error
If you previously worked around this with a separate date column or manual 00:00:00 concatenation, you can drop that glue.
A small but very welcome batch of expression helpers landed on the query builder. They replace patterns that used to need raw SQL or awkward orWhere() gymnastics.
notBetween() — the missing partner to between():
$query->where(function (QueryExpression $exp) {
return $exp->notBetween('Articles.published', $start, $end);
});
inOrNull() / notInOrNull() — match a list or include rows where the column is NULL. This is the single most common reason people reach for raw SQL today:
$exp->inOrNull('Articles.category_id', [1, 2, 3]);
// generates: (Articles.category_id IN (1,2,3) OR Articles.category_id IS NULL)
isDistinctFrom() / isNotDistinctFrom() — null-safe equality, finally as a first-class expression:
$exp->isNotDistinctFrom('Articles.author_id', $authorId);
// matches when both are NULL too, unlike plain '='
EXCEPT / EXCEPT ALL set operations on SelectQuery:
$active = $this->Users->find()->where(['active' => true]);
$banned = $this->Users->find()->where(['banned' => true]);
$activeNotBanned = $active->except($banned);
And stringAgg() on FunctionsBuilder — a portable wrapper over GROUP_CONCAT / STRING_AGG so you no longer have to branch on driver:
$query->select([
'Articles.author_id',
'tags' => $query->func()->stringAgg('Tags.name', ', '),
])->groupBy(['Articles.author_id']);
The collection trait got five additions that round out gaps people have hit for years:
$collection = collection(['a' => 1, 'b' => 2, 'c' => 3]);
$collection->keys(); // ['a', 'b', 'c']
$collection->values(); // [1, 2, 3]
$collection->implode(', '); // "1, 2, 3"
// Conditional pipelines — no more breaking out of the chain:
$results = collection($rows)
->when($onlyActive, fn($c) => $c->filter(fn($r) => $r->active))
->unless($includeArchived, fn($c) => $c->reject(fn($r) => $r->archived))
->toList();
when() / unless() are the big quality-of-life ones — long collection chains that previously had to be torn apart for a single conditional filter() now stay fluent.
Two new sibling helpers for safe logging and partial display:
use Cake\Utility\Text;
// Mask by position
Text::mask('4111111111111111', 4, 8);
// => "4111********1111"
// Mask specific values inside a string
Text::maskValue(
'API call with token=sk-abcd1234 from user mark@example.com',
['sk-abcd1234', 'mark@example.com']
);
// => "API call with token=*********** from user ****************"
Useful in logs, audit trails, and anywhere PII or secrets accidentally end up in user-visible strings.
Mocking table classes used to be one of the rougher corners of testing. The new helper sets up a partial Mockery mock that is already wired into the table locator:
$Articles = $this->mockModel('Articles');
$Articles->shouldReceive('publishLatest')
->once()
->andReturn(true);
// Controller code that does fetchTable('Articles') now gets the mock
$this->post('/articles/publish-latest');
$this->assertResponseOk();
PHPUnit itself stopped supporting the old style of mocking with notices on newer versions — this is the supported path going forward.
A small but lovely addition for anyone using PHP enums as form options or table columns: EnumLabelTrait plus the #[Label] attribute give you human-readable labels without a switch statement:
use Cake\Database\Type\EnumLabelTrait;
use Cake\Database\Type\Attribute\Label;
enum ArticleStatus: string
{
use EnumLabelTrait;
#[Label('Draft')]
case Draft = 'draft';
#[Label('Pending review')]
case Pending = 'pending';
case Published = 'published'; // falls back to "Published"
}
ArticleStatus::Pending->label(); // "Pending review"
The label string also flows through __d() translation if you set up a domain, so it plays nicely with i18n. Combined with FormHelper::enumOptions() (now public), enum-backed select boxes become a one-liner.
Cake\Utility\Fs\Finder is a new fluent file-discovery API — pattern matching, depth limits, hidden-file handling, exclusions, cross-platform paths. It replaces hand-rolled RecursiveIteratorIterator gymnastics:
use Cake\Utility\Fs\Finder;
$finder = (new Finder())
->in(ROOT . DS . 'src')
->name('*.php')
->notPath('*/Test/*')
->depth(3);
foreach ($finder->files() as $file) {
// SplFileInfo for each match
}
Internally CakePHP already uses it for I18nExtractCommand and command scanning. The path helpers (Cake\Utility\Fs\Path::join() etc.) are also drop-in replacements for the older pathCombine() calls.
Streaming large JSON payloads without buffering them in memory is now first-class:
use Cake\Http\Response\JsonStreamResponse;
return new JsonStreamResponse(function () {
foreach ($this->Reports->find()->all() as $row) {
yield $row->toArray();
}
});
The generator emits items into a JSON array as they are produced — handy for exports and reports that would otherwise OOM your worker.
$io and $argsIn 6.0 the execute() signature drops the $io and $args arguments. 5.4 lays the groundwork by hydrating them as properties before execute() runs:
class MyCommand extends Command
{
public function execute(Arguments $args, ConsoleIo $io): int
{
// Already works today AND forward-compatible:
$this->io->out('Hello');
$this->args->getArgument('name');
return static::CODE_SUCCESS;
}
}
Migrate to $this->io / $this->args now and your commands will need no changes when 6.0 lands.
A new attribute system maps request data into typed DTO action arguments — like Symfony’s MapRequestPayload but native:
use Cake\Controller\Attribute\RequestToDto;
use Cake\Controller\Attribute\Enum\RequestToDtoSource;
public function create(
#[RequestToDto(source: RequestToDtoSource::Body)]
CreateArticleDto $dto,
): Response {
$article = $this->Articles->newEntity($dto->toArray());
// ...
}
Sources are Body, Query, Request (merge of both), and Auto (the default — picks Query for GET/HEAD, otherwise Body).
The only contract the attribute enforces on your DTO class is a single static method:
public static function createFromArray(array $data): static;
Because of that contract, the RequestToDto attribute and the cakephp-dto plugin compose with zero glue code. Every DTO generated by the plugin already ships a public
public static function createFromArray(array $data, bool $ignoreMissing = false, ?string $type = null): static
— which is exactly what core looks for. You get the strict, code-generated, IDE-friendly DTOs from the plugin plus core’s native request mapping for free.
A typical workflow:
1. Declare the DTO in your config/dto.xml:
<dto name="CreateArticle">
<field name="title" type="string" required="true"/>
<field name="body" type="string" required="true"/>
<field name="authorId" type="int" required="true"/>
<field name="tags" type="string[]"/>
<field name="publishedAt" type="\Cake\I18n\DateTime"/>
</dto>
2. Generate the immutable class:
bin/cake dto generate
This produces App\Dto\CreateArticleDto with typed getters/with*() methods, createFromArray(), and toArray().
3. Use it directly in your controller action:
use App\Dto\CreateArticleDto;
use Cake\Controller\Attribute\RequestToDto;
use Cake\Http\Response;
public function add(
#[RequestToDto] CreateArticleDto $dto,
): Response {
// $dto is fully typed and immutable — no manual hydration, no array soup.
$article = $this->Articles->newEntity($dto->toArray());
if ($this->Articles->save($article)) {
return $this->redirect(['action' => 'view', $article->id]);
}
// ...
}
No source: argument needed — Auto picks Body for the POST, Query for a GET form. The DTO’s required="true" fields throw a meaningful exception on missing input, so validation surfaces early and typed. Marshall via $dto->toArray() into an entity, or pass $dto straight into a service layer.
This is the foundation for cleaner, type-safe controller actions — expect it to grow in subsequent releases.
A handful of smaller-but-noteworthy changes that didn’t get their own section:
thephpleague/container (#18090). CakePHP has used league/container for years; 5.4 ports it into core as Cake\Container\Container so the framework controls its own evolution. League remains the default for BC; opt into the in-tree version with Configure::write('App.container', 'cake'). Tagged services land via Definition::getTags(). Plugin authors: skim it so you know it’s coming.
FormHelper now uses the HTML5 hidden attribute instead of inline style="display:none", so strict Content-Security-Policy setups (style-src without 'unsafe-inline') stop silently breaking forms.
The biggest feature to land between RC1 and RC2: Cake\Lock, a new component for distributed locking (#19370). It closes a long-standing parity gap with Symfony Lock and Laravel Cache::lock().
Cache-style configuration, with engines for Redis, Memcached, File (flock), and Null (no-op for tests). Non-blocking acquire() and blocking acquireBlocking() with timeout, lock refresh for long-running jobs, owner verification on release, plus a fluent synchronized() wrapper:
Lock::synchronized('rebuild-search-index', function () {
// Critical section runs at most once across all workers.
});
If you currently work around the gap with database advisory locks or a third-party lock library, this is the one to swap in and stress-test during the RC window.
The RC window is short. Before 5.4.0 lands, please:
Every issue caught now is one that does not bite someone after the stable tag. Thanks for testing.
Most applications eventually need a lightweight way for users to respond to content. A single like button works for some flows, ratings work for others, but there is a useful space in between: reactions.
The dereuromark/cakephp-reactions plugin adds that layer to CakePHP
applications. It lets users react to any model record with emoji literals such
as 👍, ❤, or 🚀, or with named keys such as thumbsup, heart, or
rocket. It is built for CakePHP 5.1+ and follows CakePHP conventions:
behaviors for model integration, helpers for view rendering, a plugin
controller for normal form posts, an optional component for action-local
handling, migrations for schema setup, and configuration through Configure.
The core rule is simple:
model + foreign_key + user_id + reaction
That means a user can add multiple different reactions to the same record, but
cannot add the same reaction twice to that record. One post can have both 👍
and 🎉 from the same user, and many users can all add their own 👍.
The plugin is meant for the multiple-reaction case. If you only need a single
binary opinion, such as star, favorite, or like, dereuromark/cakephp-favorites
is the smaller fit. If you need weighted scores, averages, or star ratings,
dereuromark/cakephp-ratings is the better tool.
Use cakephp-reactions when the UI should offer several response types for the
same record:
👍, ❤, 🎉, and 👀,
👍 and 👎,
🚀,
❤ or 😄,
approved, blocked, or needs-review.
The stored reaction value is just a string. Emoji are first-class values, but the plugin does not require them.
Install the package with Composer:
composer require dereuromark/cakephp-reactions
Load the plugin and run its migration:
bin/cake plugin load Reactions
bin/cake migrations migrate -p Reactions
The current branch targets CakePHP 5.1+ and PHP 8.2+.
The migration creates one table, reactions_reactions. Each row stores the
host model name, the host record id, the reacting user id, the reaction key, and
the creation timestamp.
The important columns are:
model: the stored model string, for example Posts or Blog.Articles,
foreign_key: the primary key value of the host record,
user_id: the user who reacted,
reaction: the emoji literal or named key.
The table has a unique index across model, foreign_key, user_id, and
reaction. That is what makes writes idempotent and prevents duplicate rows for
the same user/reaction pair.
The foreign_key column is polymorphic. It is not a real database foreign key,
because one reaction table can point at many different host tables. Before
running the migration, you can choose the foreign-key column type through the
global Polymorphic.type configuration:
'Polymorphic' => [
'type' => 'integer', // integer, biginteger, uuid, or binaryuuid
],
For integer and biginteger primary keys, signedness follows
Migrations.unsigned_primary_keys.
On MySQL, the migration gives the reaction column an utf8mb4_bin collation.
That matters for emoji. Binary comparison keeps grouping and uniqueness
byte-for-byte, so different emoji do not collapse into the same comparison
weight under a broad case-insensitive collation.
Every model that can receive reactions gets the Reactable behavior:
// in src/Model/Table/PostsTable.php
public function initialize(array $config): void {
parent::initialize($config);
$this->addBehavior('Reactions.Reactable', [
'allowed' => ['👍', '👎', '❤', '🎉', '🚀'],
]);
}
The allowed option is optional. Set it to an array when you want a fixed
reaction set. Set it to null when arbitrary non-empty keys are acceptable:
$this->addBehavior('Reactions.Reactable', [
'allowed' => null,
]);
The behavior gives the host table methods for common operations:
$this->Posts->addReaction([
'modelId' => $postId,
'userId' => $userId,
'reaction' => '👍',
]);
$this->Posts->removeReaction([
'modelId' => $postId,
'userId' => $userId,
'reaction' => '👍',
]);
$result = $this->Posts->toggleReaction([
'modelId' => $postId,
'userId' => $userId,
'reaction' => '🚀',
]);
$counts = $this->Posts->reactionCounts($postId);
$mine = $this->Posts->userReactions($postId, $userId);
addReaction() is idempotent. It returns the new reaction id when a row is
created, and null when the same row already exists. removeReaction() returns
the number of deleted rows. toggleReaction() returns the action that happened
and fresh counts:
[
'action' => 'added', // or 'removed'
'counts' => [
'👍' => 3,
'🚀' => 1,
],
]
Counts are returned as an array keyed by reaction and sorted by reaction key for deterministic output.
The plugin includes a Reactions\Reaction enum for the default GitHub-style set:
use Reactions\Reaction;
$behavior = $this->Posts->getBehavior('Reactable');
$behavior->react($postId, by: $userId, with: Reaction::ThumbsUp);
$behavior->unreact($postId, by: $userId, with: Reaction::ThumbsUp);
$behavior->toggle($postId, by: $userId, with: Reaction::Rocket);
The bundled enum cases are:
ThumbsUp: 👍,
ThumbsDown: 👎,
Laugh: 😄,
Confused: 😕,
Heart: ❤,
Party: 🎉,
Rocket: 🚀,
Eyes: 👀.
Applications can also define their own string-backed enum:
enum ArticleReaction: string {
case Helpful = 'helpful';
case NeedsWork = 'needs-work';
case Approved = 'approved';
}
Then use those enum cases in the behavior config and calls:
$this->addBehavior('Reactions.Reactable', [
'allowed' => [
ArticleReaction::Helpful,
ArticleReaction::NeedsWork,
ArticleReaction::Approved,
],
]);
$this->Articles
->getBehavior('Reactable')
->react($articleId, by: $userId, with: ArticleReaction::Helpful);
The short methods react(), unreact(), and toggle() live on the behavior
instance. They are intentionally not exposed as table magic methods, because
those method names are generic and can collide with other behaviors. The
array-form table methods are still available and also accept BackedEnum
values in the reaction field.
When reaction requests go through the plugin controller or helper, the plugin uses public aliases. The alias is part of the URL and form payload, so it should be short, stable, and explicitly configured:
'Reactions' => [
'models' => [
'Posts' => 'Posts',
'Articles' => 'Blog.Articles',
],
'allowed' => ['👍', '👎', '❤', '🎉', '🚀'],
],
This gives you URLs such as:
/reactions/reactions/toggle/Posts/123
Unknown aliases are rejected. That is an important boundary: the browser should not be allowed to submit arbitrary table class names.
Load the helper in AppView:
// in src/View/AppView.php
public function initialize(): void {
parent::initialize();
$this->loadHelper('Reactions.Reactions');
}
Render the widget and counts in a view:
// in templates/Posts/view.php
echo $this->Reactions->widget('Posts', $post->id);
echo $this->Reactions->counts('Posts', $post->id);
The widget renders the current user’s selected reactions and a <details>
picker for the available reactions. Internally it uses FormHelper::postLink()
with block => true, which keeps generated forms out of nested form markup.
Your layout must output the postLink block once:
<?= $this->fetch('postLink') ?>
The helper’s default icon set is derived from Reactions\Reaction, but you can
replace it:
$this->loadHelper('Reactions.Reactions', [
'icons' => [
'thumbsup' => '👍',
'heart' => '❤',
'rocket' => '🚀',
],
'html' => '<span class="reaction%s" aria-hidden="true">%s</span>',
]);
The array key is the stored reaction value. The array value is what gets rendered.
If you need custom markup, ask the helper for the toggle URL and build the UI yourself:
$url = $this->Reactions->urlToggle('Posts', $post->id);
The plugin supports two UI submission strategies.
The default strategy posts to the plugin controller. This is the simplest setup:
echo $this->Reactions->widget('Posts', $post->id);
The controller reads the current user id from the configured session key,
validates the alias, validates the reaction key, writes the row through
ReactionsTable, refreshes counters when needed, and redirects back for normal
HTML requests.
For JSON or AJAX toggle requests, the controller returns a small payload:
{
"action": "added",
"counts": {
"👍": 3,
"🚀": 1
}
}
The second strategy posts back to the current host controller action and lets
ReactableComponent process the reaction payload:
// in src/Controller/PostsController.php
public function initialize(): void {
parent::initialize();
$this->loadComponent('Reactions.Reactable');
}
Load the helper with the action strategy:
$this->loadHelper('Reactions.Reactions', [
'strategy' => 'action',
]);
With this setup, the helper includes alias, id, reaction, and action in
the POST body. The component checks whether the current controller action is
enabled, loads the behavior when needed, validates the payload, reads the user
id, and delegates to addReaction(), removeReaction(), or toggleReaction().
Use the controller strategy when a plugin route is acceptable. Use the action strategy when reactions should be handled inside an existing page flow.
Counting reactions on detail pages is usually cheap. On list pages, repeatedly counting reactions for many records can get expensive. For that case, enable the optional counter cache.
Add a column to the host table:
$this->table('posts')
->addColumn('reactions_count', 'integer', [
'default' => 0,
'null' => false,
])
->update();
Enable it on the behavior:
$this->addBehavior('Reactions.Reactable', [
'counterCache' => true,
'fieldCounter' => 'reactions_count',
]);
The plugin recomputes the total count after add, remove, and toggle operations. Recomputing costs a little more than incrementing, but it self-heals after manual data changes. If the configured counter column does not exist, the write is skipped, which makes staged deployments easier.
Direct calls to ReactionsTable::add() or ReactionsTable::remove() bypass the
behavior. Use the behavior for normal application writes when you want counters
to stay in sync automatically.
The behavior adds two finders.
Use reactions to load one host record with its reactions and reacting users:
$post = $this->Posts
->find('reactions', id: $postId)
->firstOrFail();
Use reactedBy to find host records a user has reacted to:
$posts = $this->Posts
->find('reactedBy', userId: $userId)
->all();
These finders are useful for detail pages, profile activity, notification features, and dashboards.
By default, reactions belong to Users through user_id. If your application
uses another user table class, configure it:
$this->addBehavior('Reactions.Reactable', [
'userModelClass' => 'Accounts',
]);
For a fully custom association:
$this->addBehavior('Reactions.Reactable', [
'userModel' => 'Authors',
'userModelConfig' => [
'className' => 'Accounts.Users',
'foreignKey' => 'user_id',
],
]);
The reaction table uses that association for its existsIn rule, so invalid
user ids are rejected at the table layer.
The plugin ships an admin listing for reaction rows:
/admin/reactions
Access is deny-by-default. You must configure Reactions.adminAccess with a
closure that returns literal true for allowed requests:
use Cake\Http\ServerRequest;
'Reactions' => [
'adminAccess' => function (ServerRequest $request): bool {
$identity = $request->getAttribute('identity');
return $identity !== null && in_array('admin', (array)$identity->roles, true);
},
],
The admin backend can list reaction rows and filter by configured models.
The plugin keeps the browser-facing surface intentionally narrow.
Do not trust submitted user ids. The controller and component read the current user id from the server-side session configuration. When you call the behavior directly, pass the authenticated user id from your own server-side identity logic.
Always configure Reactions.models for models exposed through the controller
strategy. The submitted alias is public input. The plugin rejects aliases that
are not mapped.
Use allowed when arbitrary reaction keys are not part of your product design.
The behavior and controller both enforce the configured allow-list.
Reaction entity foreign keys are not intended for direct mass assignment. Write through the behavior or table methods so validation, uniqueness, and counters stay consistent.
Keep the admin backend closed until your application explicitly allows the
request through adminAccess.
If reaction buttons render but do nothing, check your layout first:
<?= $this->fetch('postLink') ?>
This should be placed just before the closing </body> tag.
The helper stores generated forms in the postLink block. Without fetching
that block, the visible links will not have the backing POST forms.
If you see an invalid alias error, verify your Reactions.models config:
'Reactions' => [
'models' => [
'Posts' => 'Posts',
],
],
If a reaction key is rejected, check the behavior-level allowed config first.
The controller uses the host table’s loaded behavior config when available and
falls back to Configure::read('Reactions.allowed').
If counters do not update, confirm that the host table has the behavior loaded,
that counterCache is enabled, and that the configured counter column exists.
For a simple posts application, the setup can be as small as this.
Configuration:
'Reactions' => [
'models' => [
'Posts' => 'Posts',
],
'allowed' => ['👍', '👎', '❤', '🎉', '🚀'],
'sessionKey' => 'Auth.User',
'userIdField' => 'id',
],
Posts table:
public function initialize(array $config): void {
parent::initialize($config);
$this->addBehavior('Reactions.Reactable', [
'allowed' => ['👍', '👎', '❤', '🎉', '🚀'],
]);
}
View helper:
public function initialize(): void {
parent::initialize();
$this->loadHelper('Reactions.Reactions');
}
Template:
echo $this->Reactions->widget('Posts', $post->id);
echo $this->Reactions->counts('Posts', $post->id);
Layout:
<?= $this->fetch('postLink') ?>
That gives you a working, POST-based reaction widget with server-side validation, duplicate protection, and count rendering.
Check out the sandbox examples for a live demo.
cakephp-reactions deliberately keeps the data model small and the integration
points idiomatic. The plugin does not force one presentation style or one set of
emoji. It gives your CakePHP application a dependable reaction table, a
Reactable behavior, a helper for the common UI, controller and component flows,
typed enum support, optional counters, and secure defaults around public aliases
and admin access.
That makes it useful for the common social-style reaction UI, but also for business workflows where the reaction keys are not emoji at all. In both cases, the same core rule holds: a user can express several distinct reactions to a record, and each distinct reaction is stored once.
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
<?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 !== ’’ && strstartswith($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.