K KodePressdocumentation
Laravel 11 · MySQL 8 Specification Built 2026-10-10

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:

  1. Verify the zip structure and the manifest against a schema; reject anything unexpected.
  2. Verify the version requirements.
  3. Static-check every PHP file: schema.php must be a single return [...] of literals — no function calls, no new, no includes, no superglobals. A schema.php that is anything other than a data literal is rejected.
  4. 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).
  5. Extract into storage/app/block-packs/<slug>/, which is a configured block path.
  6. 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#

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#

KodePress documentation · generated from the Markdown sources by tools/build-docs-site.py · internal preview, not indexed.