Dan Matthew

Program note · Writing · 6 Sep 2026

My plugins cannot read your content, and that was on purpose

The first thing I refused to give a NimbusCMS plugin was the one thing you would expect a CMS plugin to have: the ability to read your content. Here is why, and the narrow door I opened when my own app needed it.

#nimbuscms #php #plugins #security

The first time I gave a plugin a home in NimbusCMS, the thing I refused to give it was the one thing you would expect a CMS plugin to have. It could not read your content.

A Nimbus plugin gets a context object that hands it exactly the seams it is allowed to use: its own database tables, a capability to declare, admin pages, tools an agent can call, events, contributions to the page head. What that object deliberately did not expose was any way into the site's content collections. A plugin could not list your posts or open an entry. If it tried, there was simply nothing to call, and the docblock said so in as many words: no service locator, no reach through.

Why default to no

A plugin is third party code you chose to install. The moment a plugin can freely read all content, "install this plugin" quietly means "give this code every draft, every unpublished entry, every field on every collection." I did not want the plugin boundary to be a polite suggestion, so the default was no access at all.

Then I hit my own wall

Then I built a restaurant on Nimbus, and its orders screen needed to read the menu. The menu is just priced items, so it belongs in a content collection, not in plugin tables. And there was my own rule, standing in the way, which is exactly the moment a rule earns its keep.

The lazy fix was to punch a hole: hand every plugin a content reader and move on. I did not want the lazy fix, because the next plugin would use the same hole for something less innocent than a lunch menu.

A narrow door instead of a hole

So I added one thing and constrained it hard: a read only content facade. It exposes four methods and no more. List a collection's entries, count them, get one by id, get one by slug.

The important word is published. The facade reads through the same published only rule the public site uses, so a plugin sees exactly what an anonymous visitor on the public API would see, and not one row more. A draft is invisible to it. Relations are expanded the same way the public view expands them. There is no write path anywhere on it.

So the restaurant reads its menu, a shop could read its catalog, a docs plugin could read its pages, and none of them can see a draft or reach a field the public would never be shown. The capability has its own written decision record, so the boundary is documented rather than folklore.

The test that keeps it honest

The failure I was actually afraid of is a draft leaking through the get by id method, because "get entry 42" is easy to write without the published check that the get by slug method obviously needs. So there is a regression test that asks the facade for a drafted entry by id and asserts it comes back empty. If a future refactor ever drops that predicate, the test goes red before a draft can reach a plugin.

What it will not do

It is read only and published only, and that is the entire point, but it does mean a plugin that genuinely needs another plugin's private data cannot use this. Those cases go through an explicit, typed service contract between the two plugins, where the owner decides exactly what to share, rather than a general reach into content. And it is single site, because Nimbus is single site today.

The useful shape here is not "add a content reader." It is "when your own boundary blocks you, add the narrowest door you can, never a hole." The facade gives a plugin precisely what the public already sees, and the type of the thing makes the leak I feared impossible rather than merely discouraged.