Integrating another plugin
This document explains how a plugin that shares a Laravel application with this CMS integrates with it. Examples of such plugins are a product information manager (PIM), a catalog and an events system. It is written for the developer building that plugin. Neither plugin needs to reference the other's class names. The CMS provides two integration points: a route resolver for owning URLs, and an external reference for being referenced from CMS content. The document also lists the events the CMS dispatches, the plugin's responsibility for the page cache, and the practices to avoid.
When the data belongs to the application itself rather than to another plugin, and it should appear on a page that an editor still controls block by block, read Your application's own data first. That document compares three approaches. Owning a URL, described below, is one of them, and it removes the most editor control: a page served by a resolver has no version behind it and therefore no blocks an editor can change.
1. Owning URLs
The public site resolves every request through a chain of RouteResolver implementations ordered by priority. A plugin that registers a resolver serves every path that resolver claims.
<?php
declare(strict_types=1);
namespace Acme\Pim\Http;
use Mcg\FilamentCms\Contracts\RouteResolver;
use Mcg\FilamentCms\Support\RequestContext;
use Mcg\FilamentCms\Support\Resolution;
class ProductResolver implements RouteResolver
{
public function resolve(string $path, RequestContext $ctx): ?Resolution
{
if (! str_starts_with($path, '/products/')) {
return null;
}
$product = Product::query()
->where('slug', basename($path))
->where('is_active', true)
->first();
if ($product === null) {
return null;
}
return Resolution::external('pim::product.show', ['product' => $product]);
}
public function priority(): int
{
return 50;
}
}
- In the plugin's service provider, in
boot(), register the resolver:
$this->app->make(\Mcg\FilamentCms\Support\ResolverChain::class)
->register(new ProductResolver);
ResolverChain calls resolvers in ascending order of priority() and uses the first non-null Resolution. The CMS's own content resolver, CmsContentResolver, has priority 100:
| Resolver priority | Effect |
|---|---|
| Lower than 100, for example 50 | The resolver runs before the CMS and serves a path that also matches stored content |
| Higher than 100, for example 150 | The resolver receives only paths that no stored content matched |
A resolver declines a path by returning null, and the chain continues to the next resolver. Do not throw an exception to signal that a path belongs to another resolver.
Resolution::external($view, $data) returns rendering entirely to the plugin: the CMS renders the named view with the given data. Resolution::content($content, $version) is the value the CMS returns for its own pages. A plugin does not construct it.
The path a resolver receives
The $path argument never includes a locale prefix. The package guarantees this as part of the resolver contract. On a multilingual site, a request for /fr/products/anvil reaches the resolver as /products/anvil. A resolver written before multilingual content existed therefore works unchanged, and no resolver needs to read the locale list. DetectsCmsLocale removes the prefix and replaces the route's path parameter before the controller runs.
The requested language is available on the RequestContext:
public function resolve(string $path, RequestContext $ctx): ?Resolution
{
// '/products/anvil' in every language.
$product = Product::query()->where('slug', basename($path))->first();
// $ctx->locale is 'fr' here, and null when multilingual content is
// disabled. The fallback below gives the default locale in that case.
$locale = $ctx->locale ?? config('cms.scoping.default_locale');
}
$ctx->path holds the same path without the prefix, for a resolver that reads it from the context instead of the argument. Do not call $request->path() in a resolver. It still includes /fr, which is correct for the canonical and og:url tags and incorrect for any comparison with stored data.
2. Being referenced from content
An editor who places a product on a CMS page needs a field to choose the product. A plugin provides that field by implementing ExternalReference:
<?php
declare(strict_types=1);
namespace Acme\Pim\Cms;
use Filament\Forms\Components\Select;
use Filament\Schemas\Components\Component;
use Illuminate\Support\Collection;
use Mcg\FilamentCms\Contracts\ExternalReference;
class ProductReference implements ExternalReference
{
public function key(): string
{
return 'pim.product';
}
public function label(): string
{
return 'Product';
}
public function field(string $name): Component
{
return Select::make($name)
->label($this->label())
->options(fn () => Product::query()->orderBy('name')->pluck('name', 'id')->all())
->searchable();
}
public function resolve(array $ids): Collection
{
return Product::query()->findMany($ids)->map(fn (Product $p) => [
'id' => $p->id,
'label' => $p->name,
'url' => "/products/{$p->slug}",
'thumbnail' => $p->thumbnail_url,
]);
}
}
- In the plugin's service provider, in
boot(), register the reference:
\Mcg\FilamentCms\Facades\ExternalReferences::register(ProductReference::class);
The CMS stores only the reference's key and the selected ids. Namespace each key with the plugin's name, for example pim.product and pim.category.
When the providing plugin is not installed, the CMS continues to work:
ExternalReferences::field()returns a FilamentPlaceholderlabeled with the key, which reads "The plugin that provides “pim.product” is not installed. This value is preserved but cannot be edited here."ExternalReferences::resolve()returns an empty collection.- A page that references a product still opens, still saves, and keeps its stored ids. Reinstalling the plugin restores the references without restoring a backup.
3. Reacting to content changes
The CMS dispatches twelve domain events, all in the Mcg\FilamentCms\Events namespace:
| Event | Payload |
|---|---|
| ContentSaved | $content, $version |
| ContentPublished | $content, $version |
| ContentUnpublished | $content |
| ContentDeleted | $content |
| ContentRestored | $content |
| ContentScheduled | $content, $previousPublishAt, $previousUnpublishAt |
| ContentPathChanged | $content, $from, $to |
| MediaUploaded | $media |
| MediaReplaced | $media, $previousFileName |
| RedirectCreated | $redirect |
| WebhooksChanged | $diff, $actorName |
| FormSubmitted | $submission |
Event::listen(ContentPublished::class, function (ContentPublished $event) {
SyncToSearchIndex::dispatch($event->content->id);
});
The notifications feature listens to the same twelve events. An announcement in Slack or by email for any of them is configured as a notification setting, without writing a listener; see Notifications.
MediaUploaded is dispatched from Spatie Media Library's MediaHasBeenAddedEvent, not from the media model's created event. The media row is written before the file, and the row is force-deleted if writing the file fails. When MediaUploaded is dispatched, the file is already on the disk.
ContentPathChanged. The CMS already listens to ContentPathChanged and creates the 301 redirect for the move. It also collapses redirect chains, so after /a is moved to /b and then to /c, both /a and /b redirect directly to /c. Do not create a second redirect for the same move, because it conflicts with the chain collapsing. Listen to ContentPathChanged to update the plugin's own references to the URL, and leave the redirect to the CMS. A move also dispatches RedirectCreated, so a listener on both events receives two events for one rename.
The two broadcast events
ContentUpdatedForViewers ($content, $versionId, $isPublished) and SiteUpdatedForViewers ($fingerprint) are not domain events. They implement ShouldBroadcastNow and exist only to tell a browser that already displays a page to reload its content; see Drafts. ContentUpdatedForViewers is dispatched on both save and publish, carries no blocks, and is dispatched inside a try/catch block so that a broadcasting failure cannot fail the write.
Do not attach application logic to either broadcast event. A listener on ContentUpdatedForViewers runs twice for a save followed by a publish, and does not run at all when broadcasting throws an exception. Listen to ContentSaved and ContentPublished instead.
4. Cache participation
The CMS caches each page as a whole, and it has no automatic invalidation for data that belongs to another plugin. When a product name appears on a cached CMS page, the plugin is responsible for invalidating that page when the product changes. The PageCache contract, what each of its methods removes, and how to bind a custom implementation, such as one that purges a CDN, are described in Caching.
5. Practices to avoid
- Do not extend
Content. It is the CMS's model. Relate the plugin's own tables to it by id. - Do not write
content_versionsrows directly. Versions are immutable and the model enforces it. Write throughVersionManager; a row written directly corrupts the version history that the editor interface depends on. - Do not register a catch-all route. The CMS registers its catch-all route inside
$this->app->booted()so that application and plugin routes are matched first. A second catch-all route either takes precedence over the CMS's route or is overridden by it, depending on the order the service providers load, and the failure appears as 404 responses on pages that exist. - Do not hard-code the panel path, and do not read
config('cms.panel.path'). That value is the path the CMS requests, not necessarily the path it is served at. When the CMS is installed as a plugin on a panel the application already had, it is served at that panel's path and the config value is not used. CallCmsPanel::url(), which reads the URL from the panel that actually registered the CMS.