Skip to content
MCG Filament CMS Demo

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():

PHP

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:

PHP

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

<?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():

PHP

\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 Filament Placeholder labeled 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:

Domain events in Mcg\FilamentCms\Events and their payload properties
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

PHP

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_versions rows directly. Versions are immutable and the model enforces it. Write through VersionManager; 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. Call CmsPanel::url(), or Resource::getUrl(..., panel: CmsPanel::id()), which read the URL from the panel that registered the plugin.