Custom features are self-contained modules under app/CustomFeatures/ — the
client-owned extension layer of WebProCMS. They are the CMS's equivalent of
WordPress plugins: real Laravel/Livewire code (own database tables, dashboard
pages, public routes) that installs, updates, and version-controls
independently of the CMS itself.
The ownership contract in one line: commit = global, install-only = local.
Anything committed to the CMS repo ships to every install (that's what
app/Features/ is for); anything under app/CustomFeatures/ belongs to one
website (or to whoever distributes the module) and is never touched by a CMS
update. The CMS .gitignore excludes the whole tree, and release packages are
built from git ls-files, so an update can neither ship nor overwrite a
custom module.
Who can do what
| Capability | Requires |
|---|---|
| Run installed modules, receive their updates | nothing — never gated, a lapsed subscription won't break a site |
| Install modules (ZIP upload / update feed) | any active membership (Pro plan or above) |
Build modules (make:custom-feature, dev:init-repo) |
the Enterprise plan (config('cms.developer_tier'), env CMS_DEVELOPER_TIER) |
The license server and author-mode installs (CMS_AUTHOR_MODE=true) are
exempt from both gates. Setting CMS_DEVELOPER_TIER="" disables the developer
gate entirely.
Module anatomy
app/CustomFeatures/AcmeInventory/
├── manifest.json ← identity: key, name, version, update_url
├── AcmeInventoryServiceProvider.php ← auto-registered when the manifest is valid
├── routes/
│ ├── cms.php ← dashboard routes (web + auth + verified + role + feature:acme-inventory)
│ └── web.php ← public routes (web + CacheResponse + feature:acme-inventory)
├── resources/views/
│ ├── dashboard/⚡index.blade.php ← referenced as acme-inventory::dashboard.index
│ └── public/⚡index.blade.php
├── Database/Migrations/ ← run on install/update and by php artisan migrate
└── vendor/ ← optional committed composer deps (see below)
- Discovery — CustomFeatures scans
app/CustomFeatures/*/manifest.json; CustomFeaturesServiceProvider registersApp\CustomFeatures\{Studly}\{Studly}ServiceProviderfor every valid manifest. No registration file to edit. - Enable/disable — toggled per-install from Settings → Features →
Website-Specific; the
feature:{key}middleware 404s the module's routes while it's off. Disabling never drops tables. - Vendored dependencies — a module may commit its own
vendor/; the scaffolded provider bootstrapsvendor/autoload.phpwhen present. No composer runs on client installs. - Dashboard visibility — module dashboard pages have no automatic sidebar
entry; link them from your pages or register a dashboard-home card via
DashboardWidgetRegistry.
Building a module (Enterprise)
php artisan make:custom-feature AcmeInventory --feature-description="Inventory sync for Acme"
scaffolds the full anatomy above with working starter pages at
/dashboard/acme-inventory and /acme-inventory.
Version control — your repo, not ours
php artisan dev:init-repo
initializes a git repository at the project root that tracks only the
client-owned trees — app/CustomFeatures/, resources/themes-custom/,
resources/design-library-custom/ — via .git/info/exclude plus per-tree
.gitignore re-includes (all of which live outside the release package, so a
CMS update can't disturb them). Push it to your own GitHub. Because the CMS
and your code never overlap on disk, a CMS package update applies around your
repo without ever dirtying git status — no merge conflicts, by construction.
Do not run it on a git-engine install or the CMS author checkout (it
refuses unless --forced).
Distributing modules to other installs
-
Package:
cd app/CustomFeatures/AcmeInventory && zip -r ../acme-inventory.zip .(manifest.jsonat the archive root; one directory below also works). -
Install: the receiving install (any active membership) uploads the zip under Settings → Features → Website-Specific → Install Feature. FeatureInstaller validates the manifest, guards against path traversal, extracts, and runs the module's migrations.
-
Auto-update: set
update_url(https) in the manifest. Installs poll daily (features:check-updates) and apply per their per-feature auto-update mode (Manual / Automatic minor / Automatic major). Two feed shapes are accepted (FeatureUpdateFeed):{"version": "1.2.0", "notes": "…", "download_url": "https://…/module.zip", "sha256": "…", "min_cms_version": "1.0.0"}or a GitHub release — point
update_urlathttps://api.github.com/repos/you/repo/releases/latest:tag_name/bodymap to version/notes, and the first.zipasset supplies the download URL and its sha256digest. Tag a release on GitHub and every install running your module picks it up on its next daily check.Private feeds can require a Bearer token — each install stores it in the per-feature
updateTokenSetting. (GitHub'sbrowser_download_urlonly works for public releases; distribute private modules from your own feed.)
Extending the page builder (custom items + primitives)
Beyond whole rows (sections), the Enterprise developer surface lets you add two
finer-grained page-builder pieces. Both live in the gitignored
resources/design-library-custom/ tree (already whitelisted by dev:init-repo),
so they survive CMS updates and version-control in your own repo.
Custom items — new entries in the in-row "Add Item" picker
A reusable snippet (a stat block, a pricing badge, a branded callout) that editors drop into any row.
php artisan make:dl-item pricing-badge --name="Pricing Badge" --icon=tag --category=marketing
writes items/pricing-badge.blade.php (the snippet — compose any x-dl.*
components, including your own primitives; __SLUG__/__PREFIX__ are
substituted on insert) and items/pricing-badge.json (picker metadata: name,
icon, category, context, prefix, optional min_role / feature gate). It
appears in the editor's Add Item picker immediately — discovered by
CustomDesignLibrary::items(), merged into RowItemLibrary. A new category
becomes its own picker heading (label it in an optional
items/_categories.json).
Custom primitives — brand-new x-dl.* building blocks
A first-class component with its own editable fields in the sidebar, usable inside any row or item.
php artisan make:dl-primitive stat-tile
writes components/dl/stat-tile.blade.php (the component) and
schemas/stat-tile.php — a versioned envelope:
return [
'schema_abi' => 1,
'fields' => function (array $attrs): array { /* field list */ },
];
schema_abi versions the return contract itself (the PHP twin of the JSON
manifests' manifest_version): a schema declaring an ABI newer than the CMS
understands is skipped loudly (logged; the primitive still renders, its editor
field list is just empty) instead of being mis-read. The bare ABI-1 shorthand —
returning the callable(array $attrs): array (or a plain field array)
directly — stays supported forever. content($slug, KEY, …) reads in the
blade must match the schema keys.
Use it anywhere as <x-dl.stat-tile slug="…" prefix="…" />. Its fields render
in the page editor's design/content sidebar just like a core primitive.
Core always wins. Custom primitives resolve through an additional view
location registered after the shipped one, so a custom slug can never shadow
a core x-dl.* component. The scaffolder and the discovery layer both reject a
custom item key / primitive slug that collides with a core identifier (mirrors
the custom-feature manifest-key rule). Nest custom primitives inside a core
<x-dl.section> / <x-dl.wrapper> (the normal row-authoring convention) so
their fields attribute cleanly to the enclosing editor card.
Styling just works. A custom primitive's default classes render even on a
node-free (or build) install without any extra step: the runtime CSS supplement
scans custom primitive blades and generates their utilities via the pure-PHP
generator. Use standard Tailwind utilities and the brand tokens
(primary/secondary/tone); an exotic value the pure-PHP generator can't
produce is dropped silently (same as any editor-added class), so prefer
utilities the shipped rows already use for anything unusual.
Why CMS updates and custom code never conflict
Client installs update the CMS via the package engine: a sha256-verified
release zip built from the CMS repo's git ls-files, overlaid onto the
install after a pre-update snapshot. Since app/CustomFeatures/,
resources/themes-custom/, and resources/design-library-custom/ are never
in that file list, the overlay flows around them. Your git repo tracks only
those trees, so the update is invisible to it — and your deploys are invisible
to the CMS. Two layers, one seam, zero merges.