Integrating another package
This page explains how a package that shares a Laravel application with the CMS integrates with it. Examples of such packages are a product information manager (PIM), a catalog and an events system. It is written for the developer building that package. Neither package needs to reference the other's class names. The integration points are owning URLs, being referenced from content, reacting to content events, and cache participation.
Owning URLs
The CMS registers a catch-all route, so by default every path no other route matches is looked up as a page. A package with its own URLs registers a RouteResolver on the CMS's resolver chain instead of registering a competing route. Each resolver receives the path and returns a Resolution when it recognizes the path, or null to pass it to the next resolver. Register the resolver in the package's service provider, in boot():
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))->first();
return $product === null
? null
: Resolution::external('pim::product.show', ['product' => $product]);
}
public function priority(): int
{
return 50;
}
}
// In a service provider
$this->app->make(ResolverChain::class)->register(new ProductResolver);
ResolverChain calls resolvers in ascending order of priority() and uses the first non-null Resolution. CmsContentResolver has priority 100. A resolver with a lower number, such as 50, runs before the CMS and serves a path that also matches stored content. A resolver with a higher number, such as 150, receives only paths that no stored content matched. A resolver declines a path by returning null, and the chain continues. Do not throw an exception to signal that a path belongs to another resolver.
Resolution::external($view, $data) returns rendering entirely to the package: 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 package 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.
Being referenced from content
An editor who places a product on a CMS page needs a field to choose the product. A package 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,
]);
}
}
Register the reference in the package's service provider, in boot():
\Mcg\FilamentCms\Facades\ExternalReferences::register(ProductReference::class);
The CMS stores only the reference's key and the selected ids. Namespace each key with the package's name, for example pim.product and pim.category.
When the providing package 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 package restores the references without restoring a backup.
Events
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.
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 Draft view and live reload. 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.
Cache participation
The CMS caches each page as a whole, and it has no automatic invalidation for data that belongs to another package. When a product name appears on a cached CMS page, the package 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 on Caching.
Practices to avoid
- Do not extend
Content. It is the CMS's model. Relate the package'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
app()->booted()so that application and package 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 intermittent 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, and it is accurate only when the CMS runs on a panel of its own. 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 names a path that serves nothing. CallCmsPanel::url(), orResource::getUrl(..., panel: CmsPanel::id()), which read the URL from the panel that registered the plugin.