DerEuroMark View RSS

A blog about Frameworks (CakePHP), MVC, Snippets, Tips and more
Hide details



CakePHP File Storage: store it once, upload it in pieces 9 Oct 4:09 AM (yesterday, 4:09 am)

File uploading in CakePHP just became serious.

Last November I introduced the FileStorage plugin v4.0: one file_storage table, Flysystem underneath, image variants on top. The post ended with a roadmap. A lot of it has shipped since, and some things that were not on it at all. 5.3.0 adds resumable uploads, so large files can now reach the server in chunks.

This continues the tour. The second half covers how the plugin now stores identical content once, how a browser can skip an upload entirely, and how a 1 GiB file gets uploaded in chunks and survives a dropped connection.

The 4.x line in one breath

These came in small releases:

  • Admin backend (4.3). A standalone Bootstrap 5 UI at /admin/file-storage with a dashboard, filters, bulk delete, downloads through any adapter and a cleanup workflow with dry run. Access is denied by default: supply a closure in FileStorage.adminAccess that returns true, or set it to true when your own middleware already protects the admin routes.
  • Signed URLs that deliver (4.4). SignedUrlGenerator::url() builds a link to /file-storage/signed/{uuid}/{signature}. On local storage the response supports HTTP Range requests, so <video> and <audio> can seek.
  • Queued variant generation (4.4). bin/cake file_storage generate_image_variant --queue hands each entity to a cakephp-queue job instead of blocking the shell.
  • AVIF and WebP (4.4). ImageHelper::picture() looks up AVIF and WebP variants of an image and renders a <picture> element with the base variant as fallback.
  • MIME checks on the real content (4.2). hasAllowedMimeType() detects the type from the file content with finfo by default, instead of trusting the browser’s Content-Type.
  • Fixes that only bite at scale (4.4). A failed variant processor now tries to remove the file its save had already stored, and the cleanup scan streams rows instead of loading hundreds of thousands of them into memory.

5.0: integer ids, and moving to S3

5.0 separated the database key from the public identity. Fresh installs use an integer file_storage.id, and a unique uuid column carries the identity that URLs and storage paths use. Public and signed URLs therefore contain UUIDs, not sequential row ids; who may download a file is still decided by your serving code or the signature. Existing installs can keep their primary key and add a backfilled uuid column. The upgrade guide covers both paths, and also the re-keying of imageVariants by model that 5.0 requires.

5.0 also brought bin/cake file_storage migrate_adapter. It copies files from one adapter to another, Local to S3 for example, and updates the rows, with dry run, scope and limit options and optional deletion of the source.

Every upload knows its content

Since 5.1 every new upload stores a hash of its content in file_storage.hash, SHA-256 by default. Everything below builds on it.

Store it once: deduplication

In many applications the same file arrives over and over: the company logo in every signature, the terms PDF attached to every order, the meme the whole team forwards. 5.2 can store such content once, for the collections you opt in:

'FileStorage' => [
'hashAlgorithm' => 'sha256',
'deduplicate' => [
'collections' => ['Documents' => ['Attachments' => true]],
],
],

Each upload still gets its own row, with its own uuid, filename, owner and variants. Only the stored original is shared, once per storage adapter. It lives below blobs/, named by its hash. A new table file_storage_blobs records each stored file, and every row that uses it points there through blob_id.

A few design details I like:

  • A blob never changes. Replacing a file on one row gives new content and so a new hash and a new blob. The other rows keep what they had. There is no ”split on edit” step that could go wrong, because there is nothing to split.
  • Two first uploads of the same file at once do not both write it. On MySQL and PostgreSQL the upload claims the blob’s registry row with a row lock, and the second upload waits and then finds the file already there.
  • Deleting a row never deletes a blob directly. The cleanup command removes blobs that no row references any more, and only after a grace period, one hour by default, since an upload last used them. If you keep file rows without a foreign_key on purpose, run bin/cake file_storage cleanup --blobsOnly: the full cleanup also removes such rows.
  • Existing files can join later: bin/cake file_storage deduplicate converts the files stored before you switched it on. It has a dry run and never deletes or overwrites a file. The old files stay until you remove them, and signed URLs of converted rows change, since their path does.

Everything that reads a file through its row, the serving controller, signed URLs and the image helper, works as before. The deduplication guide covers the requirements: the migration, atomic saves, and MySQL or PostgreSQL for concurrent uploads.

Skip the upload entirely

Once the server knows hashes, the browser can ask before it sends anything. It hashes the file locally and sends only the hash and the filename. If the content is already stored, BlobAttacher::attach() creates the new row from the existing blob, and the file itself never travels. Variants are not generated on attach; queue them if you need them.

Hashing a large file in the browser has its own trap. Web Crypto’s crypto.subtle.digest() has no streaming API: it wants the whole file in one buffer, so a 2 GB file means 2 GB of memory. The sandbox demos use hash-wasm, which hashes the file slice by slice, and fall back to Web Crypto only for smaller files.

The part that needed the most thought was not technical, though. If anyone can attach any hash, then knowing a hash is enough to get the file. And the plain answer “already stored” versus “please upload” tells a stranger whether some document exists on your server. So attaching is denied by default, and you decide per request:

use Cake\Core\Configure;
use FileStorage\Service\BlobAttacher;
Configure::write('FileStorage.deduplicate.attachAuthorizer',
static function (string $hash, array $data, array $context): bool {
$userId = $context['userId'] ?? null;
// Only content this user already owns.
return $userId !== null && (new BlobAttacher())->userOwnsHash($userId, $hash);
},
);

That rule covers the most common case, someone uploading the same file again to a different record, without telling anyone anything new. Take the user id from authentication, and still check that the user may attach to the target record.

Straight into the bucket

Some applications do not want large uploads to pass through PHP at all. The usual pattern is a presigned URL: the browser uploads directly to S3 under a temporary key, and the application takes over afterwards.

BlobImporter, new in 5.3, is the building block for that. It registers an object that already sits on an adapter as a blob and copies it through the adapter, so S3 can copy it on the server side:

use FileStorage\Service\BlobAttacher;
use FileStorage\Service\BlobImporter;
$claim = (new BlobImporter())->import('S3', $temporaryKey, 'report.pdf', $hash, [
'deleteSource' => true,
]);
$file = (new BlobAttacher())->attach($hash, [
'adapter' => 'S3',
'model' => 'Documents',
'collection' => 'Attachments',
'filename' => 'report.pdf',
'user_id' => $userId,
], ['userId' => $userId]);

The ownership rule from above cannot allow this attach, since nobody owns the content yet. Your authorizer has to recognize the direct upload it issued, for example through the record you created along with the presigned URL.

By default the importer still reads the object once through PHP, to check its SHA-256 against the hash you expect. If S3 already verified that checksum on a single PUT, ['verify' => false] skips the read.

Upload it in pieces: resumable uploads

This is the newest part, released in 5.3.0.

A browser uploads a file in chunks, up to a configurable size, 5 GiB by default. When the connection drops, the laptop goes to sleep or the tab is closed, it continues where it stopped instead of starting over; after a reload the user selects the same file again. The wire format is a subset of tus 1.0.0, so tus-js-client and Uppy work without any custom client code.

The plugin does not register the upload routes. You add two:

use Cake\Routing\RouteBuilder;
$routes->plugin('FileStorage', function (RouteBuilder $routes): void {
$routes->connect('/uploads', ['controller' => 'Uploads', 'action' => 'collection']);
$routes->connect('/uploads/{id}', ['controller' => 'Uploads', 'action' => 'resource'], ['pass' => ['id']]);
});

Plus an authorizer in FileStorage.resumable.authorizer. It is called for creating, reading, writing, deleting and consuming an upload, and returns the owner as ['userId' => '42'], or false. Without it, every request gets 403.

A finished upload does not create a file row by itself, because a tus upload carries one file and no form. Your application saves its own entity first and then consumes the upload into it:

use FileStorage\Service\ResumableUploads;
$document = $this->Documents->saveOrFail($document);
$file = (new ResumableUploads())->consume(
$uploadId,
['foreign_key' => $document->id, 'user_id' => $identity->id],
['userId' => (string)$identity->id],
);

consume() runs the normal validation, deduplication, variant and save event pipeline in its own transaction. It copies the completed file into storage and generates variants synchronously, so plan disk space and request time for that last step. If it fails, the upload stays complete and can be consumed again. The resumable uploads guide has the full setup, including the tus-js-client configuration, the CSRF token and the FileStorage.uploadCompleted event.

If your application uses the CakePHP Authorization plugin, the upload actions count as authorized: the authorizer closure is their access check, so put any policy you need there.

What happens under the hood

Each chunk is appended to a part file on the application server, under an exclusive lock per upload. A competing write, consume or delete gets a 423 instead of corrupting it, while HEAD can still report the offset. The bytes count only once they are written, hashed, synced to disk and recorded in the database, in that order. If PHP dies halfway, the next request finds a few extra bytes at the end of the part file and cuts them off. All of this assumes one application server, or a shared staging directory where file locking works.

Hashing only at the end would mean reading the whole file once more inside the last request, which for a 4 GB file can run into a timeout after the client has already sent everything. PHP can serialize a HashContext, so the plugin stores the half-finished SHA-256 state with every chunk and continues it with the next one. When the last byte arrives, the digest is ready. It contains NUL bytes, so it is stored base64 encoded.

Quotas are checked at creation: file size, uploads and bytes per user, the total reserved on disk and a free space floor. An upload keeps its reservation until its part file is gone, even after it expired, so schedule bin/cake file_storage cleanup --uploadsOnly.

Four review rounds before the first line of code

I wrote the design as a spec first and had it reviewed four times before implementing anything. The reviews found 47 issues. A few examples, to show what “resumable” means in practice:

  • What if a chunk request is still writing while a consume of the same upload finishes?
  • What if the server crashes after creating the part file but before the database row exists?
  • How do you count disk space for uploads that are half done, without being fooled by a chunk written between two measurements?
  • What should DELETE answer for an upload that was already turned into a file?

Then the tests found what no review did:

  • MySQL counts rows differently: an UPDATE that changes no value reports zero affected rows there, while the code needed the matched rows. An empty PATCH at the current offset is legal in tus, so this would have failed. Every update now increments a revision column.
  • The column offset worked on SQLite and MySQL and broke every upload on PostgreSQL, where OFFSET is reserved. It is upload_offset now. Running the suite against all three databases locally before pushing found it within minutes.
  • The first browser test behind an HTTPS proxy failed: the upload URL came back as http://, because TLS ends at the proxy and PHP never sees it. A browser on an HTTPS page refuses that. tus allows a relative Location header, so the server now returns a path only.

Tested with real gigabytes

Besides the test suite, I ran manual end-to-end tests against a local ddev setup. A 1 GiB file in 20 MiB chunks, aborted at 720 MiB, was picked up again by a fresh client at exactly the server’s offset and stored with the correct hash. A 600 MiB file uploaded twice ended up stored once. In the browser, a 300 MiB upload was paused, the page reloaded, the same file selected again, and the upload continued at 91 MB.

Two findings went straight into the docs:

  • In that setup, FrankenPHP, PHP’s post_max_size did not apply to PATCH bodies: a 150 MiB chunk went through with a limit of 100M. Check the body limits of your own web server and proxy; they cap a chunk, never the file.
  • When a connection dropped in the middle of a chunk there, the bytes that had arrived were kept, and the client continued from there. Behind a proxy that buffers request bodies, the offset stays at the last complete chunk instead. Either way the client resumes from what HEAD reports.

Try it

The sandbox has live demos for deduplication and instant uploads: upload a small file, up to 2 MB there, then upload it again and watch it attach without sending its content.

The resumable upload demo is different. A public page that accepts anonymous 1 GiB uploads is a free file host for strangers, so the live page only explains the feature. To try pause, resume and resume after closing the tab, run the sandbox locally with debug mode on, for example with ddev.

The documentation has a guide per feature, and the upgrade guide lists the migrations to run.

Not only for CakePHP

The content addressing starts in the framework-agnostic library. php-collective/file-storage 1.1 adds a ContentHashInterface for files that carry their hash and a hashPathTemplate that builds storage paths from it, so any PHP application can store files under their content hash. The registry, locking, attach authorization and cleanup on top are the CakePHP plugin’s part.

What’s next

The resumable demo already hashes a file before uploading it. The next step is to use that hash first: attach the file if its content is known, and start the chunked upload only if it is not. Both halves exist now.

If you run the plugin with large files or many duplicates, I would like to hear how it behaves for you. Issues and pull requests are welcome on GitHub.

Add post to Blinklist Add post to Blogmarks Add post to del.icio.us Digg this! Add post to My Web 2.0 Add post to Newsvine Add post to Reddit Add post to Simpy Who's linking to this post?

From Markdown to Carve 24 Sep 6:08 AM (16 days ago)

A practical migration: convert a Markdown file, inspect the few syntax changes that matter, and add linting before publishing.

Trying a new markup language should not begin with retyping your archive. Carve has enough in common with Markdown that most of a document can move mechanically: headings, links, images, block quotes, code spans, fenced code, and ordinary lists all have familiar forms. The parts that differ are the ones worth reviewing rather than the whole file.

This walkthrough shows how to convert one real Markdown-shaped article, check the result, and then use a few Carve features that remove the HTML usually hiding in a Markdown document.

Start with the converter

Install one of the Carve command-line implementations. With Rust and Cargo installed, the same command installs the Rust binary on Linux, macOS, and Windows:

cargo install carve-lang

Then convert a file:

carve migrate --from markdown article.md > article.crv

The JavaScript and PHP command-line implementations expose the same migration command. If you are integrating conversion into an application, equivalent APIs are available in JavaScript, Rust, and PHP.

The converter handles the mechanical work. It is not a promise that every Markdown extension has an equivalent, because “Markdown” may mean CommonMark, GFM, Pandoc Markdown, kramdown, or a site-specific mixture. Treat the generated file like the result of a code migration: review it once, then let tooling keep it valid.

Review the four differences that matter most

Most source survives unchanged. These are the places where a quick review pays off.

1. Emphasis uses visual mnemonics

Markdown overloads stars and underscores for italic and bold. Carve assigns one visible effect to each delimiter:

/italic text/
*bold text*
/*bold and italic*/
_underlined text_
~struck text~
=highlighted text=

The mnemonic is the syntax: a slash leans, a star is visually heavy, an underscore sits below the word, and a tilde crosses through it.

This is the change most likely to fool your eyes during a manual copy. In particular, _text_ is underline in Carve, not italic. The converter rewrites parsed Markdown emphasis, so do not run a global search-and-replace over the raw source.

2. Put heading attributes above the heading

Pandoc and kramdown commonly put an id at the end of a heading:

## Installation {#install}

In Carve, block attributes precede their block:

{#install}
## Installation

This is not cosmetic. A trailing {#install} is heading text in Carve and can change the generated id, breaking links while still producing plausible HTML. Because the Markdown is still valid text, find these before converting:

rg '\{[#.]' content --glob '*.md'

The converter preserves a trailing attribute as escaped literal text, such as \{\#install}, rather than guessing which Markdown dialect authored it. The linter does not flag that escaped form. Use the search above as the check, then remove the backslashes and move {#install} above the heading in the generated Carve file.

3. Give lists a blank line

Carve does not let a list marker interrupt a paragraph. This text stays one paragraph:

The release has two phases:
1. Prepare the package.
2. Publish it.

To start a list, add a blank line:

The release has two phases:
1. Prepare the package.
2. Publish it.

That rule prevents an ordinary line beginning with a number from unexpectedly changing the surrounding block structure. It also removes Markdown’s special case in which an ordered list may interrupt a paragraph only when it starts at 1.

Markdown’s + item bullet is another special case: + is a continuation marker in Carve, not a bullet. Use - or * for unordered lists.

4. Make raw output explicit

Bare HTML written directly in Carve is text. During migration, however, the converter preserves Markdown’s parsed HTML by writing an explicit {=html} raw span or =html block. That keeps trusted articles working, but it also means converted HTML remains live by default.

When trusted source really needs target-specific output, the explicit block form is:

```=html
<video controls src="demo.webm"></video>
```

This is easier to find in review and only fires for the named renderer. Disable raw HTML when converting or rendering untrusted input:

carve migrate --from markdown comments.md | carve --safe

Or in JavaScript:

carveToHtml(userInput, { allowRawHtml: false })

URL and attribute hardening remains active independently, but explicit raw output is a trust boundary rather than a sanitizer.

Let the linter find silent mistakes

Run the linter on the converted document:

carve lint article.crv

It catches problems that a parser cannot reject because they are still valid text: a broken cross-reference, a duplicate heading id, a hand-written Markdown-style heading attribute in the wrong place, doubled Markdown emphasis, or a legacy raw fence. As noted above, it does not flag a trailing heading attribute that the converter has already escaped.

For a directory of articles, make linting part of CI. find avoids relying on shell-specific recursive glob settings:

find content -name '*.crv' -exec carve lint {} +

A clean lint result means the linter’s known silent traps are absent. It does not replace the pre-conversion heading-attribute search or a rendered preview, especially when the original depended on a Markdown plugin.

Compare the HTML shape and anchors

Carve wraps a heading and its following content in a <section> by default, placing the heading id on that wrapper. It also preserves case in generated ids:

<!-- A typical Markdown renderer -->
<h2 id="page-heading">Page Heading</h2>
<p>A paragraph.</p>
<!-- Carve's default HTML -->
<section id="Page-Heading">
<h2>Page Heading</h2>
<p>A paragraph.</p>
</section>

Audit inbound fragment links and CSS or JavaScript that expects every rendered block to be a direct child of the content container. An explicit heading id stabilizes links. If the wrapper is incompatible with an existing site, check whether your engine version supports rendering with sections: false.

Replace workarounds only after the move

First get an equivalent document. Then simplify the parts that were awkward in Markdown.

A callout no longer needs embedded HTML:

::: warning "Back up first"
The migration changes stored source files. Commit or copy them before running
it over a directory.
:::

A figure can carry a real caption and a stable cross-reference:

{#migration-flow}
![A document moving through conversion, linting, and review](migration-flow.svg)
^ Figure #: The migration workflow

Later prose can refer to </#migration-flow>. The renderer supplies the figure number, so inserting an earlier figure does not leave a stale “Figure 3” in the text.

Tables can express headers without a separator row and can merge cells:

|= Stage |= Owner |= Result |
| Convert | Tool | Draft source |
| Review | Author | Approved wording |
| Publish | < | HTML and feeds |

The < cell extends the cell on its left. These are document structures, not HTML snippets, so non-HTML renderers can decide how to preserve or gracefully degrade them.

Abbreviations do not need hand-written <abbr> tags. Define a term once when it appears throughout the document:

The HTML output keeps the abbreviation's expansion.
*[HTML]: HyperText Markup Language

For a one-off abbreviation, put the expansion on the span itself:

[CSS]{abbr="Cascading Style Sheets"} controls the presentation.

Result: CSS controls the presentation.

Boolean attributes have no =value. For example, {kbd} replaces a hand-written <kbd> tag:

Press [Ctrl]{kbd} + [S]{kbd} to save.

Result: Press Ctrl + S to save.

Typed containers can replace generic <div> wrappers. Put block attributes on the line before the container:

{#compatibility .browser-note data-version="2026-09"}
::: compatibility
This fallback is required for older browsers.
:::

Without an extension for compatibility, the container remains a generic typed block. A host can later give that type a specialized rendering without changing the document source.

Migrate one boundary at a time

You do not need to switch an entire site to evaluate Carve. Start with a single article or documentation section whose build can route .crv files through a Carve renderer. Keep assets and URLs where they are, compare the rendered HTML, and add the lint command before expanding the boundary.

That produces a useful decision even if you stop: you learn which features in your documents are standard structure, which depend on a Markdown flavor, and which are really raw HTML workarounds.

The migration guide has the complete syntax map. The playground is the quickest way to test a fragment without installing anything.

Add post to Blinklist Add post to Blogmarks Add post to del.icio.us Digg this! Add post to My Web 2.0 Add post to Newsvine Add post to Reddit Add post to Simpy Who's linking to this post?

Pandoc: What survives a conversion? 18 Aug 3:26 PM (last month)

I have been working on a new (post-markdown) markup language Carve. And I wanted to know how it performs for input and output. Especially compared to Markdown and Djot, which mainly inspired me here.

And it happens that Pandoc released 3.10.2 recently.

Everyone who converts documents knows the process loses things. Tables are fine in most formats. LaTeX keeps everything. Word is a black hole.

Some of that is true. Some is backwards. You cannot tell by looking, because the failure is quiet – the file still opens, the paragraphs are all there, and the one attribute that carried your meaning is gone.

Why the initial experiment failed

Write a document, convert it, read it back, diff against the original. That is maybe the intuitive way – but it does not really work.

Every pandoc writer normalizes as it serializes. Hand the markdown writer a document and it rewraps your lines, renumbers your ordered lists, moves your link definitions, and writes emphasis with * even where you typed _. None of that changed the meaning, and all of it shows up in a diff.

So the diff is mostly noise. Tighten it and you are reading reformatting; loosen it until the reformatting drops out and you have loosened it past the thing you were trying to detect.

The way around it is to stop comparing against the input at all.

The approach

Every probe is a pair of documents differing in exactly one feature: a rich one that uses it, a degraded one that does not. Both go through the same writer, same options, same run. Two questions follow.

expressed
Are the outputs different at all? A writer that emits byte-identical text for a document with the feature and one without cannot express it. Nothing was encoded, so nothing can be recovered.
unchanged
Read both outputs back. Compare the resulting syntax trees node by node. If the trees are identical, the round trip ate it.

Both documents take the same path, so canonical styling lands on both and cancels. When a writer scores “cannot express”, pandoc emitted the same bytes for two different documents. No formatting preference produces that.

The gap

{"type":"bar","data":{"labels":["carve","markdown","html","commonmark_x","epub","djot","latex","gfm","docx","commonmark","rtf","pptx"],"datasets":[{"label":"expressed","data":[64,59,63,57,62,52,54,50,53,56,40,40],"backgroundColor":"#7f93b3"},{"label":"survives a round trip","data":[64,46,42,39,34,33,24,24,23,21,15,0],"backgroundColor":"#2a78d6"}]},"options":{"indexAxis":"y","color":"#888888","scales":{"x":{"max":66,"ticks":{"color":"#888888"},"grid":{"color":"rgba(128,128,128,0.25)"},"title":{"display":true,"text":"probes (of 66)","color":"#888888"}},"y":{"ticks":{"color":"#888888"},"grid":{"color":"rgba(128,128,128,0.25)"}}},"plugins":{"legend":{"position":"top","labels":{"color":"#888888"}}}}}
Figure 1: Light bar is what the writer can express, dark bar is what comes back unchanged, and the distance between them is the loss.
Table 1: 66 probes, pandoc 3.10.2.
FormatExpressedUnchangedLost
native (the AST itself)66660
Carve, AST lane65650
Carve, source lane64640
markdown594613
html634221
commonmark_x573918
epub623428
djot523319
latex542430
gfm502426
docx532330
commonmark562135
rtf401525
pptx40040

HTML expresses 63 of 66 and returns 42. It is a rendering target: the writer encodes for display, and the reader has to guess intent back out of presentation. A third of what it writes does not survive that guess.

LaTeX drops 30. Word drops 30. CommonMark drops 35 – the specification written to make Markdown unambiguous, which turns out to be a separate question from making it expressive.

pptx is the clean case. Expresses 40, returns zero. Every distinction it can draw is one no reader recovers. Converting out of PowerPoint is archaeology.

Where Carve lands

Carve expresses 64 and returns 64. Through the AST lane, 65 and 65.

The rank matters less than the shape: both columns are the same number. Almost everything on that table can write more than it can read, and the difference is where your document quietly changes meaning. Carve keeps what it can express.

That comes from one decision, made early and not revisited – awkward things get syntax rather than becoming an extension somebody bolts on. Per-cell table alignment. Colspan and rowspan. Captions. Attributes on nearly every node. Footnotes, definition lists, line blocks, raw blocks addressed to one target.

A feature with a spelling can be found again. A feature without one gets degraded into prose by the writer, and parser quality is irrelevant at that point.

native sits above Carve on the table. That row is pandoc’s own syntax tree written to disk, kept as the 66/66 reference – if your format is the data structure, round-tripping is not an achievement.

Run it yourself

Format comparisons are usually feature checklists, and a checklist cannot tell you that HTML drops a fifth of what it writes, or that pptx returns nothing, or that the interesting number is the distance between two columns rather than either one.

The harness is public. The probes are one file. Every number here regenerates with make, and if one looks wrong it can be checked – open a ticket or PR here please.

The harness and the full grid

Add post to Blinklist Add post to Blogmarks Add post to del.icio.us Digg this! Add post to My Web 2.0 Add post to Newsvine Add post to Reddit Add post to Simpy Who's linking to this post?

Tailwind/DaisyUI drop-in for CakePHP 8 Aug 3:11 AM (2 months ago)

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.

The 30-second version

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.

Why another UI plugin?

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.

What you get — and where it’s actually better

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.

1. Checkbox and radio groups

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.

2. Class composition via the ClassMap

Say you want every btn-primary in your app to have a subtle shadow.

Bootstrap-ui path:

  1. Subclass BootstrapUI\View\Widget\ButtonWidget
  2. Register your subclass in FormHelper
  3. Override render() to inject shadow-sm
  4. Hope nothing breaks on upgrade

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.

3. No JavaScript dependency

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.

4. Preset swapping at runtime

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 ClassMap — actually, what’s in there?

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.

What about KTUI / Metronic?

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.

Bake theme

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.

A real example

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 roadmap

The plugin is a ~90% drop-in replacement for bootstrap-ui. The 10% is captured in issue #1:

  • Inline form layout (align => 'inline')
  • staticControl() method
  • Floating labels
  • Label tooltips
  • Tooltip-style validation error feedback

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.

Try it

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.

Add post to Blinklist Add post to Blogmarks Add post to del.icio.us Digg this! Add post to My Web 2.0 Add post to Newsvine Add post to Reddit Add post to Simpy Who's linking to this post?

Why Carve markup Changes How You Author Rich Text in Shopware 6 17 Jul 10:15 AM (2 months ago)

One Source, Ten Surfaces

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.

The problem with the status quo

Content in a shop is not one thing. The same paragraph might need to appear as:

  • HTML on a product detail page,
  • plain text in a <meta> description,
  • the plain-text part of a multipart email,
  • an export feed for a marketplace.

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.

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:

ProfileWhat it allows
minimalInline formatting + paragraphs and lists only. Great for chat/micro-posts.
commentBasic inline + lists/quotes/code. No headings, images, tables, raw HTML.
articleEverything except raw HTML. For trusted blog/article authors.
noneNo 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.

One source, ten surfaces

Here’s the whole surface area the plugin exposes, from the primitive everything is built on up to the edge cases:

The ten surfaces
SurfaceWhat you get
|carve Twig filtersSafe HTML, plain text, or Markdown from any template. The universal primitive.
Carve CMS elementA drag-and-drop safe rich-text block in Shopping Experiences.
Product field carve_bodyStructured, diffable product copy under the description.
Category fieldRich landing copy at the top of a category listing.
Manufacturer fieldAuthored brand story on the manufacturer entity.
Admin live previewByte-identical WYSIWYG powered by carve-js, no API roundtrip.
Transactional mailOne 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:renderRender 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.

PHP/JS parity is the quiet superpower

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.

What authoring feels like

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.
A product description in Carve source – reads like plain text, renders semantic HTML.

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:

  • diffable in git,
  • reviewable in a PR,
  • translator-friendly (no markup soup to preserve),
  • never dependent on a fragile editor’s serialization.

Styling and structure without raw HTML

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:

  • Anchors and cross-links. Put {#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.
  • Semantic wrappers. A bare ::: promo fence renders <div class="promo">. Wrap a call-to-action or a legal block, style it per-theme, skip the raw <div>.
  • Captioned figures and tables. A ^ 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.

Why it’s genuinely useful

  • Determinism. Same input, same output, everywhere. The storefront, the mail, the export, and the admin preview never drift apart.
  • Safety you don’t have to think about. The dangerous stuff is neutralized at the renderer level. You can hand a carve_body field to a junior author or a customer and the worst case is ugly text, not a compromised checkout.
  • Channel reuse. |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.
  • Live commerce inside prose. :product[SKU] is the thing Markdown fundamentally can’t offer: authored copy with real, resolved commerce entities in it.
  • No editor lock-in. The source outlives any WYSIWYG. Migrate themes, migrate shops, the .crv still renders.

Integration ideas and use cases

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.

Ready out of the box

  1. Editorial product pages. Put the marketing narrative in carve_body and let the core description stay a short summary. Buyers get a real story; merchandisers get a diffable field.
  2. SEO category landing copy. Rich intro text at the top of category listings – headings, a note callout, a bulleted value prop – without touching a theme file.
  3. Brand storytelling. The manufacturer field turns “who makes this” into an actual narrative surface. Render it in a brand card wherever your theme shows the maker.
  4. Consistent transactional mail. Author the order-confirmation body once; the HTML and plain-text parts come from the same Carve. No more “the text email looks like garbage” tickets.
  5. Safer product reviews. Flip on 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.

Patterns worth building

Click to expand: five more ideas
  • Docs / knowledge base inside the shop. Carve’s code-group, tabs, admonitions, and tables make it a legitimate lightweight docs engine for setup guides or FAQs – no separate CMS.
  • Custom inline elements. The same render-hook that powers :product[SKU] lets you add :price[SKU], :badge[new], :stock[SKU], or a legal-snippet include that resolves against live data at render time.
  • Invoice / delivery-note copy. The filters work in document Twig overrides too – put localized legal or thank-you copy on the order and render it into the PDF.
  • Flow Builder mail bodies. Store a Carve message on a custom order field and render both mail parts from it inside a Flow.
  • Print / terminal previews. The CLI renders the same source to ANSI for a terminal or to Markdown for a static export – handy for content ops and CI checks.

The developer ergonomics

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 #}
The three render channels, one source – HTML, plain text, and context-resolved.

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.

The honest caveats

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.

Beyond the extension

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:

  • Documents. carve-pdf turns a plain template into a paginated PDF – datasheets, spec sheets, delivery-note inserts. Need LaTeX, Typst or DOCX instead? pandoc-carve bridges to every pandoc writer. Whatsapp/Telegram/Discord/Slack notifications? chat-export should have you covered.
  • Non-PHP services. The same source parses in Rust, Go, Python and Ruby. Your PIM, feed exporter or ERP glue renders byte-identical output without shipping a PHP runtime.
  • Headless storefronts. carve-components gives you React and Vue renderers, and carve-wasm runs the Rust parser in the browser.
  • Editing. carve-lsp plus first-party plugins for VS Code, JetBrains, Zed, Sublime, Vim/Neovim, Helix and Emacs – diagnostics and live preview wherever your team already works.
  • WYSIWYG when you want it. carve-wysiwyg (Tiptap + carve-grammars) round-trips to Carve source, so a visual editor never becomes lock-in.
  • Shop-adjacent apps. Blog on WordPress (wp-carve), internal tools on Laravel or Symfony, docs site on Astro, Eleventy, Hugo or MkDocs – one house style, one syntax.
  • Agent-authored copy. carve-skill teaches Claude Code and friends to emit valid Carve, so generated product copy lands as reviewable plain text in a PR – not as HTML soup in a database column.

Video walkthrough

The walkthrough below shows Carve content in Shopware 6 with live prices, stock information and shared includes. It complements the examples above with a look at the plugin in use.

YouTube

Watch the Shopware 6 Carve plugin walkthrough on YouTube.

Bottom line

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.

  1. Use |carve when there are definitely no inline product references, and↩

Add post to Blinklist Add post to Blogmarks Add post to del.icio.us Digg this! Add post to My Web 2.0 Add post to Newsvine Add post to Reddit Add post to Simpy Who's linking to this post?

Twenty years of Markdown hindsight, in one markup language: Carve 13 Jul 11:34 AM (2 months ago)

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.

The syntax looks like what it does

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.

Your identifiers are safe

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.

One syntax, one meaning

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 >.

Tables that can do real work

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.

Twenty years of paper cuts, fixed

Small things you stopped noticing you were fighting:

  • Hard line breaks are visible. A \ 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.
  • Comments exist. %% starts a line or trailing comment, %%% fences a block comment. No more abusing <!-- --> and hoping your renderer does not pass it through.
  • One escape rule. A backslash before any ASCII punctuation makes it literal. No memorizing which characters are escapable in which context.
  • Frontmatter is in the spec. A leading --- block is defined behavior (with ---toml and ---json variants), not a convention that every static site generator reinvents slightly differently.

Document-grade features, natively

Everything you normally bolt on with HTML, LaTeX or preprocessors:

  • Footnotes, both reference style ([^note]) and inline (^[like this])
  • Citations with CSL-JSON bibliographies ([@knuth84])
  • Cross-references: </#setup> renders a link that adopts the heading text automatically – rename the heading, the link text follows
  • Numbered figures, tables, listings and equations: write ^ Figure #: A sunset under an image and the # becomes the next number; cross-references then resolve to “Figure 1”
  • Admonitions (::: note), definition lists, verse blocks, math, glossary and index generation, a table-of-contents directive
  • Attributes on anything: {.class #id key=value}

And no raw HTML by default – which is not a limitation but a feature, because:

Security is part of the spec

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.

Identical output, everywhere

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.

Migration is a command, not a rewrite

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.

Try it

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.

Add post to Blinklist Add post to Blogmarks Add post to del.icio.us Digg this! Add post to My Web 2.0 Add post to Newsvine Add post to Reddit Add post to Simpy Who's linking to this post?

CakePHP 5.4 is around the corner 28 Jun 4:25 PM (3 months ago)

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.

Installing the RC

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.

ORM: new default eager loading strategy

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.

ORM: transaction commit callbacks finally fire correctly

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.

ORM: stricter entity class checks

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.

Catch it at static-analysis time too

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.

ORM: type-safe unhydrated reads

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().

DateTimeType accepts date-only strings

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.

New query expression methods

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']);

Collection convenience methods

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.

Text::mask() and Text::maskValue()

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.

TestCase::mockModel() with Mockery

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.

Enum labels

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.

A fluent filesystem utility

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.

JsonStreamResponse

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.

Forward-compat: Commands now auto-hydrate $io and $args

In 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.

RequestToDto attribute

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;

Pairing with cakephp-dto

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.

Also landed

A handful of smaller-but-noteworthy changes that didn’t get their own section:

  • In-tree DI container forked from 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.
  • CSP-compatible hidden form inputs (#19414). 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.

New: Lock component

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.

What we need from you

The RC window is short. Before 5.4.0 lands, please:

  1. Pull the RC into a non-production branch of your app and run your full test suite.
  2. Re-run a few heavy endpoints — the eager-loading default flip is the most likely place to see surprises (good or bad).
  3. Try your plugins against the RC. Plugin maintainers, this is the moment to publish a 5.4-compatible tag.
  4. File issues on cakephp/cakephp with a minimal reproduction.

Every issue caught now is one that does not bite someone after the stable tag. Thanks for testing.

Add post to Blinklist Add post to Blogmarks Add post to del.icio.us Digg this! Add post to My Web 2.0 Add post to Newsvine Add post to Reddit Add post to Simpy Who's linking to this post?

CakePHP Reactions: 👍, ❤️, or 🚀 15 Jun 2:49 PM (3 months ago)

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 👍.

Where this plugin fits

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:

  • articles with 👍, ❤, 🎉, and 👀,
  • comments with 👍 and 👎,
  • roadmap items with 🚀,
  • support replies with ❤ or 😄,
  • private business-domain keys such as 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.

Installation

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 database model

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.

Attach the behavior

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.

Typed reactions with enums

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.

Configure public aliases

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.

Render a reaction widget

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);

Choose a request strategy

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.

Counter caches for list views

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.

Querying reacted content

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.

Custom user models

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.

Admin backend

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.

Security model

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.

Troubleshooting

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.

A complete minimal setup

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.

Demo

Check out the sandbox examples for a live demo.

Closing thoughts

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.

Add post to Blinklist Add post to Blogmarks Add post to del.icio.us Digg this! Add post to My Web 2.0 Add post to Newsvine Add post to Reddit Add post to Simpy Who's linking to this post?

CakePHP is now fully generics-able 5 Jun 5:48 PM (4 months ago)

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.

  • CakePHP >= 5.3.6 (the template declarations across ORM, view and event layers) – initially 5.3.4 already, but some more fine-tuning was necessary
  • dereuromark/cakephp-ide-helper >= 2.19.3 (emits the matching generic doc-blocks)

What actually changed

Two things had to line up: the framework had to declare the generics, and the tooling had to emit the matching doc-blocks.

1. The core became generic

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.
  • The 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.

2. The tooling caught up

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.

The last gap

Flipping the switch got us most of the way. One small leak remained, fixed upstream:

  • Helper base class. The annotator never emitted an 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.

One LSP gotcha worth remembering

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.

The result

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.

Add post to Blinklist Add post to Blogmarks Add post to del.icio.us Digg this! Add post to My Web 2.0 Add post to Newsvine Add post to Reddit Add post to Simpy Who's linking to this post?

A powerful composable menu builder for CakePHP 27 May 9:03 AM (4 months ago)

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 pipeline: build, resolve, render

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.

Building: PHP, arrays, or a database row

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:

  • Section headers (addHeader) render as a non-link <li> — perfect for grouping a sidebar.
  • Dividers (addDivider) render the <hr> / separator semantic without you handling escape rules.
  • Icons and badges are first-class options, not strings you smuggle through the label.
  • displayChildren = false renders the item as a leaf even when it has a submenu — useful when an admin section has child items that shouldn’t appear in the chrome but should still resolve as active.

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.

Tree manipulation: reorder, move, merge, split

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.

Named menus and the helper

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.

The rest of the helper

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.

Resolvers: active state and visibility, without if-trees in your menu code

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.

Active matching, all the way down

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.

Renderers: pick the shape, keep the tree

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.

A small detour: keeping the tree honest

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.

Things you only notice once you’ve used it for a week

  • 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.
  • The default 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.

Why it’s small on purpose

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.

Get it

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.

Add post to Blinklist Add post to Blogmarks Add post to del.icio.us Digg this! Add post to My Web 2.0 Add post to Newsvine Add post to Reddit Add post to Simpy Who's linking to this post?