17 — Extensibility#
Three levels, in increasing order of power and of the trust required to use them:
| Level | Who | Adds | Phase |
|---|---|---|---|
| A block folder | a developer with repo access | one block type | 1 |
| A block pack (zip) | an Admin, via the UI | several blocks + templates | 5 |
| A plugin | an Admin, via the UI or SSH | blocks, routes, admin screens, migrations, content types | 5 |
17.1 Level A — a block folder#
Covered in 04-block-system.md. The
contract in one sentence: a folder under app/Blocks/<Name>/ with schema.php, view.blade.php and
preview.png becomes a block in the palette, and no admin code changes.
That property is load-bearing and is asserted by a test (block_added_at_runtime_appears_in_palette)
which creates a temporary block folder during the test run. If adding a field type is ever needed to
express a new block, add the field type to the generated-form system — never special-case the block
in the editor.
17.2 Level B — block packs#
A zip with a manifest:
pack.json
blocks/Testimonial/{schema.php,view.blade.php,preview.png,block.css}
blocks/LogoWall/{...}
templates/section-testimonials.json
lang/{bn,en}/blocks-<pack>.php
assets/ (optional images used by templates)
{
"name": "Marketing Blocks",
"slug": "marketing-blocks",
"version": "1.2.0",
"author": "Example Ltd",
"requires": { "kodepress": ">=1.0", "php": ">=8.3" },
"blocks": ["testimonial", "logo_wall"],
"templates": ["section-testimonials"],
"checksum": "sha256:..."
}
Import (Settings -> Block packs -> Import) does, in order:
- Verify the zip structure and the manifest against a schema; reject anything unexpected.
- Verify the version requirements.
- Static-check every PHP file:
schema.phpmust be a singlereturn [...]of literals — no function calls, nonew, no includes, no superglobals. Aschema.phpthat is anything other than a data literal is rejected. - Compile every Blade file and reject any that contains
@php,{!! !!}outside an allow-list, or a call to anything outside a small allow-list of helpers (__,kp_media,kp_url,kp_token). - Extract into
storage/app/block-packs/<slug>/, which is a configured block path. - Refresh the block manifest, import the templates, merge the language files.
A block pack cannot register routes, run migrations, or execute code at boot. It is data plus templates. That is what makes it safe to import from the UI. Anything more powerful is a plugin, which is installed over SSH by someone who accepts the risk.
Export produces the same structure from the blocks and templates an Admin selects, including a checksum.
17.3 Level C — plugins (Phase 5)#
plugins/property-listings/
├── plugin.json manifest
├── src/
│ ├── PropertyListingsServiceProvider.php
│ ├── Models/Property.php
│ ├── Http/Controllers/PropertyController.php
│ ├── Livewire/PropertyManager.php
│ └── Policies/PropertyPolicy.php
├── blocks/PropertyGrid/{schema.php,view.blade.php,preview.png}
├── database/migrations/2026_11_01_000000_create_properties_table.php
├── resources/views/
├── lang/{bn,en}/property.php
└── routes/{web.php,admin.php}
{
"name": "Property Listings",
"slug": "property-listings",
"version": "1.0.0",
"provider": "KodePress\\PropertyListings\\PropertyListingsServiceProvider",
"requires": { "kodepress": ">=1.0" },
"adds": { "blocks": ["property_grid"], "content_types": ["property"], "admin_screens": 2 },
"permissions": ["property.view", "property.manage"],
"settings_screen": true
}
Lifecycle#
| State | What happens |
|---|---|
| Installed | files present, a plugins row with enabled = 0; nothing boots |
| Enabled | the provider is registered at boot, its migrations run, its permissions are created and granted to Admin, its block path is added, the block manifest and HTML cache are cleared |
| Disabled | the provider is not registered; its tables and data remain; pages using its blocks render that block as nothing publicly and as a clear placeholder in the editor |
| Uninstalled | the down hook may drop its tables after an explicit, typed confirmation and a forced backup; files removed; permissions removed |
Disabling must never break a page. The renderer treats an unknown block type as empty output with a
logged warning, and the editor shows This block comes from a plugin that is turned off with the
option to delete it. This is the single most important plugin rule — a site must not go down because
someone toggled a checkbox.
What a plugin may do#
| Capability | How |
|---|---|
| Add blocks | a blocks/ directory, registered as a block path |
| Add admin screens | Livewire components plus routes/admin.php, mounted inside one of the 7 sidebar items (a plugin may add a sub-item, never a top-level one) |
| Add public routes | routes/web.php, registered before the catch-all |
| Add tables | its own migrations, table names prefixed with the plugin slug |
| Add permissions | declared in the manifest, created on enable |
| Add settings | a settings screen under Settings, values stored in its own table or in plugins.settings |
| Add a content type | its own model and screens; it may also create pages rows if it wants the page editor, but it may not alter core tables |
| Hook into events | the published event list below |
| Add translations | its own lang/ files, merged under its namespace |
What a plugin may not do#
- Add a top-level sidebar item (the 7-item rule).
- Modify core tables, core migrations, or core files.
- Write to
pages,template_partsormenusschema; it may create and read rows through the models. - Bypass
BlockRegistry,HtmlCacheorCacheInvalidator— it calls them. - Register middleware globally, or alter the public request lifecycle outside its own routes.
- Ship its own copy of a dependency that core already provides.
Events a plugin may listen to#
| Event | Fired when |
|---|---|
PageWasPublished, PageWasUnpublished, PageWasDeleted, PageWasRestored |
the page publish pipeline runs |
TemplatePartWasPublished |
a header/footer publish |
MenuWasChanged |
any menu or item write |
ThemeWasSaved |
tokens compiled |
MediaWasUploaded, MediaWasDeleted, MediaWasReplaced |
media writes |
FormWasSubmitted |
after a submission is stored |
SiteWasCreated |
Phase 5 multi-site |
CacheWasCleared |
with the scope that was cleared |
Listeners run synchronously (QUEUE_CONNECTION=sync), so a slow listener slows a publish. The
plugin docs state a 200 ms budget per listener and anything heavier must defer its work to the
scheduler.
Extension points other than events#
| Point | Mechanism |
|---|---|
| Field types | FieldTypeRegistry::extend('map', MapField::class) — adds a field type usable by any schema |
| Block contexts | read-only; a plugin declares contexts on its own blocks |
| Renderer filters | PageRenderer::filter('html', fn($html, $ctx) => ...) — last resort, documented as such |
| Cache key parts | HtmlCache::addKeyPart('currency', fn($ctx) => ...) — for a plugin that genuinely varies output |
| Dashboard panels | Dashboard::addPanel(...) with a Livewire component |
| Doctor checks | Doctor::addCheck(...) so a plugin verifies its own requirements |
17.4 Multi-site (Phase 5)#
Already prepared for: every content table carries site_id and a global SiteScope is applied from
Phase 0.
What Phase 5 adds:
| Piece | Behaviour |
|---|---|
| Resolution | ResolveSite matches sites.domain against the request host, falling back to is_default |
| Admin switcher | a site picker in the top bar; the chosen site is kept in the session and drives the scope |
| Access | a user with users.site_id = NULL reaches every site; otherwise only theirs (plus a site_user table if a user needs several but not all) |
| Isolation | the scope is applied in the model boot, not in each query; a test asserts site A cannot read site B at the model, route and cache layers |
| Media | each site has its own folder root under media/site-<id>/ |
| Cache | already keyed by site_id |
| Theme, menus, parts | per site, because every row has site_id |
| Shared templates | a templates row with site_id = NULL is visible to every site (that is how built-ins already work) |
Nothing about multi-site changes the editor. An owner who never adds a second site never sees any of it — the switcher is hidden when only one site exists.
17.5 Upgrade safety for extensions#
- The tree format is versioned and upcast on read (04.10), so a block pack built against format v1 keeps rendering after a v2.
schema.phpkeys are additive; an unknown key is ignored, so a pack built for a newer KodePress degrades rather than crashes.- The plugin API surface is exactly what this document lists. Anything a plugin reaches for beyond it is unsupported and may break on any release, and the plugin docs say so plainly.
- A
kodepress:extensions-checkcommand reports, for every installed pack and plugin, whether it declares compatibility with the running version and whether it uses anything outside the API.
KodePress documentation · generated from the Markdown sources by
tools/build-docs-site.py · internal preview, not indexed.