Skip to main content
Capell home
Live demo

The CMS is Composer packages. The site is still your Laravel app.

A few custom resources are the right answer for a few stable screens. The maintenance problem starts when pages also need URLs, layouts, previews, revisions, media, publishing rules, multiple languages, and consistent public delivery.

That repeated work gets named owners: Core for the content model, Admin for editor concerns, Frontend for public delivery, and Composer packages for optional capability.

Shared Eloquent page model Filament admin boundary Testable public output
CMS capture: page structure Page families are defined once. A real Filament screen showing the registered page structures editors use instead of rebuilding content rules inside each page.
CMS capture: package inventory Installed capabilities stay inspectable. The real extensions screen makes package ownership, version state, and operational actions visible inside the CMS.
Public page capture Published content becomes the visitor-facing page. The published delivery guide brings its overview, explained contents and linked sections into one Laravel-rendered visitor experience.

Capture date and release are not supplied in this gallery. Check the current package listing for scope and compatibility.

Real Capell product screens

Actual Capell CMS page types screen showing registered content structures and their available editor actions.

Own less repeated CMS work

Keep project-specific code while sharing the page infrastructure you would otherwise rebuild.

Capell is useful when your Laravel project needs repeatable public pages and an editorial workflow in Filament. It gives page definitions, history and recovery a maintained home while your application keeps its routes, presentation and business behaviour. Evaluate those boundaries with code and a representative page.

Capell page type definitions
Typed publishing definitions give templates a dependable content boundary inside Laravel.

The first custom Filament resource is usually cheaper than adopting a CMS. The decision changes when the team keeps rebuilding the same page model, URL handling, layout rules, preview path, media workflow, revision history, and publishing controls across resources or projects.

Capell turns that repeated work into explicit boundaries. Core owns the shared Eloquent content model and page types. Admin gives editors a Filament workspace. Frontend connects hydrated data to project-owned presentation. Optional capability arrives as Composer packages through public extension points.

What to inspect before adoption

  • Whether repeated page structures can be expressed as page types and shared layouts.
  • Whether Filament is acceptable for the admin even though public presentation stays independent.
  • Whether anonymous public output excludes model IDs, field paths, permissions, package internals, and signed editor URLs.
  • Whether package migrations, routes, configuration, compatibility, tests, and upgrade guidance are visible before installation.

Capell Foundation does not include a public delivery API (an optional API package is in beta), and it is the wrong fit for a non-Laravel stack or a team that rejects Filament. If a few custom resources still cover the work cleanly, keep them. Adopt the shared CMS layer when those resources have started recreating a product surface the team now has to maintain.

Draw the boundary clearly

Keep page content separate from application operations.

Define the editable values and prepare the data the public template needs. Keep model lookup, permissions and business operations out of the rendering view. A booking or account action remains application behaviour even when its introduction is a CMS page.

Use shared page types and layouts where a family repeats; retain custom development for the truly unusual page. Extend through documented package contributions rather than modifying vendor files. That makes upgrades and future maintenance easier to reason about.

Inspect the lifecycle

Treat a CMS package like a dependency you will maintain.

Review the dependency lock, configuration, migrations and public surfaces introduced by an install. Choose the domain and instance for each entitlement and run behaviour checks against representative records in that application. Static typing and test evidence support the decision but do not replace integration checks.

Include upgrade and removal consequences in the design. Record which pages rely on optional fields or widgets and how they would migrate away. A small, understood extension set is preferable to unexplained capability.

How the packages fit together

Capell uses Laravel concepts the team already knows: Eloquent models, service providers, Actions, data objects, policies, views, tests, queues, and Composer packages. The important question is whether each concern stays in its assigned layer as the site grows.

Keep the booking engine. Add maintained guidance.

Follow one appointment journey through editor fields, scoped application data and a shared view. Compare the code you adopt with the code you still own.

Choose a problem to highlight on the same appointment journey. All three explanations remain below.
  1. Choose a service and read the guidance

    Illustrative appointment: an equipment assessment at Harbour Workshop. The application supplies the service and location. A published Capell Page supplies preparation guidance. The customer continues to the existing booking service, which checks current prices, slots and eligibility.

  2. Staff maintain preparation and arrival text

    Staff edit the guide in Filament, using the preparation and arrival fields on the appointment-guide Page type. Domain facts remain outside those fields. Page History can recover Page content; it does not restore appointments, operational records or deployed templates.

  3. Use the guide for the selected location

    The same assessment at Hill Workshop has its own published guide and arrival instructions. The application validates the active service/location pairing before selecting the guide for its configured site and language. Switching this viewpoint only highlights an explanation; it changes no booking state.

A copyable specimen, with a declared host contract

This is an application integration specimen, not an installed booking connector or a customer result. No optional Bookings package is needed when the host application already books. Copy the example directory into app/AppointmentExample, add AppointmentExample\ to the application Composer PSR-4 map, and register AppointmentExample\AppointmentServiceProvider in bootstrap/providers.php. The files below are the complete source of this adapter; the host booking engine remains a prerequisite.

The host contract uses appointment_services and appointment_locations with id, site_id, slug, name and active columns; appointment_location_service has service_id, location_id and active. Enforce unique site/slug and location/service pairs in the host database. Adapt this one query to the existing application schema. These table names are specimen application code, not Capell APIs.

Set APPOINTMENT_DOMAIN to the served host and APPOINTMENT_SITE and APPOINTMENT_LANGUAGE to that host’s enabled Capell Site and Language, with an enabled root-host SiteDomain for the same language. This example rejects path-prefixed language domains; those need the host application’s context and URL adapter. Register an enabled, accessible Page blueprint with key appointment-guide, using the ordinary Page editor. Give its published, translated Pages canonical URLs /appointment-guides/harbour/assessment and /appointment-guides/hill/assessment. The route family /services/{location}/{service} belongs to the application, not to the CMS catch-all. The provider reserves that prefix through ReservedFrontendPathRegistry.

Create a site-owned Layout keyed appointment-guide whose metadata names the guide view appointment-example::guide and the shell view appointment-example::shell. Assign it directly to a Page or let this adapter select it through appointments.default_layout. Give the Site a Theme. The separate independent Layout uses the guide view appointment-example::independent. The example provider loads these explicit application views; theme inheritance does not silently select another master in this route.

The existing named route appointments.book must accept the location and service slugs and recheck identity, eligibility, price, availability and capacity. It must not trust the guidance response as a reservation. The specimen reads active public offerings only; any private or restricted offering needs the host’s policy checks before projection. No personal appointment state is loaded here.

One field, all the way to the response

The provider tags GuidanceFields with the actual Admin PageSchemaExtender contract. The translation hook adds only preparation and arrival text to existing appointment-guide Pages. Create the Page with the ordinary form first, then edit its guidance fields. The public route invokes AppointmentController, which calls PrepareAppointmentPageAction. Its active, site-scoped host lookup happens before Core ResolvePublicPageByUrlAction selects the published, translated guide. A different blueprint is rejected. A missing, disabled, unpublished or untranslated guide yields the stated guidance fallback, while a missing, inactive or unrelated offering yields 404 before any booking destination is prepared.

AppointmentPageData carries only the values the views need. The preparation field is escaped text, not executable HTML. The application generates the booking destination from its named route. Neither Page models nor operational records enter the template. The controller restores the previous locale after rendering, including on failure. Existing Page permissions still control who can edit the guide; these added fields grant no domain permissions.

The shared design change

Move preparation beside the service details by changing the outer grid in views/guide.blade.php. The final template below is the after state. This is presentation work shared by every consumer of that file, available equally in a well-built custom Filament application. Different preparation text stays different; the edit changes neither content nor booking rules.

This controlled trace describes the specimen configuration, not an automatically discovered production impact count. The adapter selects the Page Layout first and its site-scoped deployment default second. Theme comes from the explicit Page Layout, then the Site, then the configured Foundation theme. Both master and wrapper are allowlisted and resolved before rendering. The ordinary application route does not invoke the Frontend kernel or its experimental public-data contributor.

Shared presentation diff
--- views/guide.blade.php (before)
+++ views/guide.blade.php (after)
@@
-<div class="grid gap-6">
+<div class="grid gap-6 md:grid-cols-2">

Trace every effective file consumer

Each row ends at the actual file loaded by the example provider. Laravel application overrides under resources/views/vendor/appointment-example take precedence over the provider directory; verify the resolved View path before treating this trace as current. No override is assumed for these specimen rows.

On CMS-kernel routes, LayoutResolverStep and ThemeResolverStep resolve independently, and BladeFrontendResponseRenderer selects the master and layout views. Theme namespace registration and application overrides can change the resulting file. The Admin layout impact preview counts accessible, view-authorised Pages explicitly assigned to that Layout. It is not an inventory of inherited/default Layouts, other Layout records sharing a master, or custom application consumers. Reconcile those cases before deployment.

Trace every effective file consumer
Specimen route Page content / effective Layout Theme / master / wrapper Effect of this diff
/services/harbour/assessment Harbour preparation; explicit appointment-guide Layout Site Theme; appointment-example::guide; appointment-example::shell → views/guide.blade.php + views/shell.blade.php Affected: preparation moves beside details
/services/hill/assessment Hill arrival instructions; no explicit Layout → site-scoped appointments.default_layout Site Theme; same master and wrapper → same files Affected through the default, even without an explicit Layout reference
/services/quay/assessment Quay guide; another Layout record using the shared master Its explicit Layout Theme; same master and wrapper → same files Affected through the file, despite a different Layout record
/services/harbour/collection Collection guide; independent Layout Its explicit Layout Theme; appointment-example::independent; appointment-example::shell → views/independent.blade.php + views/shell.blade.php Unaffected: independent view does not include guide.blade.php

Where the next change lands

Where the next change lands
Change Application ownership Limit
One guide edit The published Page translation for that location/service No cross-location rollout or booking change
Shared design edit guide.blade.php and its effective file consumers A suitable Data contract is required; another view stays independent
New required content field Admin extender, stored-content migration/backfill, Action, Data and view A template-only change is insufficient
Booking rules or domain schema The host engine, scoped adapter and behaviour tests Page recovery does not recover operational records
Framework or CMS update Composer constraints, current extension contract, adapter tests, backups and deployment sequence Documented APIs do not guarantee compatibility with every upgrade

Request work and freshness, without a speed claim

On an uncached guide request, this adapter loads the Site and Language, checks their domain association, queries the joined active offering, calls Core public resolution and resolves layout/theme/view dependencies. Core performs its own URL, publication, blueprint, translation and relation work. This is source-described work, not a measured SQL count or latency. Blade only receives prepared strings. Rendering without queries does not make preparation free.

This specimen deliberately returns private, no-store and has no cross-request cache. The provider adds services/* to the optional HTML Cache bypass paths, preserving existing exclusions. Configure any external edge cache to honour no-store too. Guidance and offering changes are read again on the next request; a later booking operation must still revalidate the current domain state. The underlying read and booking transaction are not atomic. The response carries no live slot, price or eligibility assertion.

If shared guidance is cached later, explicitly track the Page, translation, URL, layout, theme and application service/location association. Invalidate after committed content, mapping or offering changes, including deletion and access changes. Optional HTML Cache has its own Discovery Foundation prerequisite and does not automatically discover arbitrary host-domain dependencies. Keep personal state private and freshness-sensitive booking checks outside cached editorial HTML.

A fair future comparison must pin source and dependency versions, host environment, output, content volume, selected packages, authentication and freshness. Measure uncached public responses, genuine warm hits, private and fragment paths, and the first response after editorial, design and operational changes. Record SQL/time, latency and delivered assets under equivalent conditions on both sides. No measurements or performance winner are claimed here.

AppointmentServiceProvider.php

AppointmentServiceProvider.php
<?php

declare(strict_types=1);

namespace AppointmentExample;

use Capell\Admin\Contracts\Extenders\PageSchemaExtender;
use Capell\Frontend\Support\Routing\ReservedFrontendPathRegistry;
use Illuminate\Support\ServiceProvider;
use LogicException;
use Override;

final class AppointmentServiceProvider extends ServiceProvider
{
    #[Override]
    public function register(): void
    {
        $this->mergeConfigFrom(__DIR__ . '/appointments.php', 'appointments');
        $this->app->singleton(GuidanceFields::class);
        $this->app->tag([GuidanceFields::class], PageSchemaExtender::TAG);
    }

    public function boot(): void
    {
        if (! is_string(config('appointments.domain')) || trim(config('appointments.domain')) === '') {
            throw new LogicException('Configure the appointment domain before registering these routes.');
        }

        $this->app->make(ReservedFrontendPathRegistry::class)->reservePrefix('services');
        // Harmless when HTML Cache is absent; explicit bypass when selected.
        config(['capell-html-cache.bypass.paths' => array_values(array_unique([
            ...(array) config('capell-html-cache.bypass.paths', []),
            'services/*',
        ]))]);

        $this->loadViewsFrom(__DIR__ . '/views', 'appointment-example');
        $this->loadTranslationsFrom(__DIR__ . '/lang', 'appointment-example');
        $this->loadRoutesFrom(__DIR__ . '/routes.php');
    }
}

GuidanceFields.php

GuidanceFields.php
<?php

declare(strict_types=1);

namespace AppointmentExample;

use Capell\Admin\Enums\PageTranslationSchemaHookEnum;
use Capell\Admin\Support\Schemas\AbstractPageSchemaExtender;
use Capell\Core\Models\Page;
use Filament\Forms\Components\Textarea;
use Filament\Schemas\Schema;
use Override;

final class GuidanceFields extends AbstractPageSchemaExtender
{
    /** @return array<int, Textarea> */
    #[Override]
    public function extendTranslationComponentsForHook(Schema $schema, PageTranslationSchemaHookEnum $hook): array
    {
        $page = $schema->getRecord();

        if ($hook !== PageTranslationSchemaHookEnum::AfterContentEditor
            || ! $page instanceof Page
            || $page->blueprint?->key !== 'appointment-guide') {
            return [];
        }

        return [
            Textarea::make('meta.preparation')->label(__('appointment-example::appointment.preparation'))->maxLength(2000),
            Textarea::make('meta.arrival')->label(__('appointment-example::appointment.arrival'))->maxLength(2000),
        ];
    }
}

appointments.php

appointments.php
<?php

declare(strict_types=1);

// Set these deployment-owned values in the host application configuration.
return [
    'default_layout' => 'appointment-guide',
    'domain' => null,
    'site' => null,
    'language' => null,
];

routes.php

routes.php
<?php

declare(strict_types=1);

use AppointmentExample\AppointmentController;
use Illuminate\Support\Facades\Route;

$domain = config('appointments.domain');
if (! is_string($domain) || trim($domain) === '') {
    throw new LogicException('Configure the appointment domain before registering these routes.');
}

Route::middleware('web')->domain($domain)->group(function (): void {
    Route::get('/services/{location}/{service}', AppointmentController::class)
        ->where(['location' => '[a-z0-9-]+', 'service' => '[a-z0-9-]+'])
        ->name('appointments.guide');
});
// appointments.book belongs to the existing application. Its handler must
// recheck identity, eligibility, prices, slots and capacity on every operation.

PrepareAppointmentPageAction.php

PrepareAppointmentPageAction.php
<?php

declare(strict_types=1);

namespace AppointmentExample;

use Capell\Core\Actions\ResolvePublicPageByUrlAction;
use Capell\Core\Models\Language;
use Capell\Core\Models\Layout;
use Capell\Core\Models\Site;
use Capell\Core\Models\Theme;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\View;

final class PrepareAppointmentPageAction
{
    public function handle(string $location, string $service): AppointmentPageData
    {
        // This route family has one deployment-owned site/language. It is not
        // selected by a visitor-supplied tenant ID or by the application locale.
        $site = Site::query()->whereKey(config('appointments.site'))->where('status', true)->firstOrFail();
        $language = Language::query()->whereKey(config('appointments.language'))->firstOrFail();
        $domain = $site->siteDomains()->enabled()
            ->where('language_id', $language->getKey())
            ->where('domain', config('appointments.domain'))->first();
        // This small example serves a language on its own root host. A path-
        // prefixed language needs the host application's URL/context adapter.
        abort_unless($domain !== null && trim((string) $domain->path, '/') === '', 404);

        // Host schema contract: these are the existing application's tables.
        // A CMS Page never grants access to an inactive or unrelated service.
        $offering = DB::table('appointment_location_service as offering')
            ->join('appointment_services as service', 'service.id', '=', 'offering.service_id')
            ->join('appointment_locations as location', 'location.id', '=', 'offering.location_id')
            ->where('service.site_id', $site->getKey())
            ->where('location.site_id', $site->getKey())
            ->where('service.slug', $service)
            ->where('location.slug', $location)
            ->where('service.active', true)
            ->where('location.active', true)
            ->where('offering.active', true)
            ->select(['service.name as service_name', 'location.name as location_name'])
            ->first();
        abort_if($offering === null, 404);

        // Segments are route-constrained; no editor-entered booking destination.
        $guidePath = '/appointment-guides/' . $location . '/' . $service;
        $guide = ResolvePublicPageByUrlAction::run($site, $language, $guidePath);
        $page = $guide->page;
        abort_if($page !== null && $page->blueprint?->key !== 'appointment-guide', 404);

        $layout = $guide->layout
            ?? Layout::query()->where('site_id', $site->getKey())->where('key', config('appointments.default_layout'))->first();
        // Theme and Layout resolve separately. These explicit application view
        // names do not depend on a theme namespace or experimental contributor.
        $theme = $page?->layout->theme ?? $site->theme
            ?? Theme::query()->where('key', config('capell-frontend.foundation_theme', 'default'))->first();
        abort_unless($theme !== null && $layout instanceof Layout, 404);
        abort_unless($layout->site_id === $site->id, 404);
        $master = $layout->meta['master_file'] ?? 'appointment-example::guide';
        $wrapper = $layout->meta['layout_file'] ?? 'appointment-example::shell';
        abort_unless(in_array($master, ['appointment-example::guide', 'appointment-example::independent'], true), 404);
        abort_unless($wrapper === 'appointment-example::shell', 404);
        abort_unless(View::exists($master) && View::exists($wrapper), 404);
        $meta = $guide->fields->meta;
        $preparation = $guide->found() && is_string($meta['preparation'] ?? null)
            && trim($meta['preparation']) !== '' ? $meta['preparation'] : __('appointment-example::appointment.missing', [], $language->code);
        $arrival = $guide->found() && is_string($meta['arrival'] ?? null) ? $meta['arrival'] : '';

        /** @var view-string $masterView */
        $masterView = $master;
        /** @var view-string $wrapperView */
        $wrapperView = $wrapper;

        return new AppointmentPageData(
            locale: $language->code,
            service: (string) $offering->service_name,
            location: (string) $offering->location_name,
            preparation: $preparation,
            arrival: $arrival,
            bookingDestination: route('appointments.book', ['location' => $location, 'service' => $service]),
            master: $masterView,
            wrapper: $wrapperView,
        );
    }
}

AppointmentPageData.php

AppointmentPageData.php
<?php

declare(strict_types=1);

namespace AppointmentExample;

final readonly class AppointmentPageData
{
    /**
     * @param  view-string  $master
     * @param  view-string  $wrapper
     */
    public function __construct(
        public string $locale,
        public string $service,
        public string $location,
        public string $preparation,
        public string $arrival,
        public string $bookingDestination,
        public string $master,
        public string $wrapper,
    ) {}
}

AppointmentController.php

AppointmentController.php
<?php

declare(strict_types=1);

namespace AppointmentExample;

use Illuminate\Http\Response;
use Illuminate\Support\Facades\App;

final class AppointmentController
{
    public function __invoke(string $location, string $service, PrepareAppointmentPageAction $prepare): Response
    {
        $appointment = $prepare->handle($location, $service);
        // Every model lookup and relation access has finished before rendering.
        $previousLocale = App::getLocale();
        App::setLocale($appointment->locale);

        try {
            $slot = view($appointment->master, ['appointment' => $appointment])->render();

            return response()->view($appointment->wrapper, [
                'appointment' => $appointment,
                'slot' => $slot,
            ])->header('Cache-Control', 'private, no-store');
        } finally {
            App::setLocale($previousLocale);
        }
    }
}

views/guide.blade.php

views/guide.blade.php
<div class="grid gap-6 md:grid-cols-2">
    <section>
        <h1>{{ $appointment->service }}</h1>
        <p>{{ $appointment->location }}</p>
        <p>{{ __('appointment-example::appointment.authority') }}</p>
        <a
            href="{{ $appointment->bookingDestination }}"
            >{{ __('appointment-example::appointment.booking') }}</a
        >
    </section>
    <aside>
        <h2>{{ __('appointment-example::appointment.preparation') }}</h2>
        <p class="whitespace-pre-line">{{ $appointment->preparation }}</p>
        @if ($appointment->arrival !== '')
            <h2>{{ __('appointment-example::appointment.arrival') }}</h2>
            <p class="whitespace-pre-line">{{ $appointment->arrival }}</p>
        @endif
    </aside>
</div>

views/shell.blade.php

views/shell.blade.php
<!doctype html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
<head>
    <meta charset="utf-8" />
    <meta
        name="viewport"
        content="width=device-width, initial-scale=1"
    />
    <title>{{ $appointment->service }}</title>
</head>
<body>
    <main>{!! $slot !!}</main>
</body>
</html>

views/independent.blade.php

views/independent.blade.php
<h1>{{ $appointment->service }}</h1>
<p>{{ $appointment->location }}</p>
<p>{{ $appointment->preparation }}</p>
<a
    href="{{ $appointment->bookingDestination }}"
    >{{ __('appointment-example::appointment.booking') }}</a
>

lang/en/appointment.php

lang/en/appointment.php
<?php

declare(strict_types=1);

return [
    'preparation' => 'Before your appointment',
    'arrival' => 'When you arrive',
    'missing' => 'Preparation guidance is not available here. Continue to the booking service for current requirements.',
    'booking' => 'Check appointments',
    'authority' => 'The booking service checks current prices, availability and eligibility.',
];

A Laravel CMS built from Composer packages

Pages remain Eloquent models. Core appends revisions after PageSaved; Admin provides Page History, field comparison, validated rollback, and roll-forward. Publishing Studio adds isolated workspaces, reviewer assignment, comments, approvals, scheduling, atomic releases and release recovery for Capell Pages.

Read the public event-history contract.

Reusable page structure
Outcome
Keep pages as Eloquent models and build the public site with normal Laravel conventions.
Source
Foundation
Maturity
Released
Page history and recovery
Outcome
Keep page-only revision history in Core while pages remain Eloquent models.
Source
Foundation
Maturity
Released
Team publishing workflow
Outcome
Keep deeper workflow isolated in an optional package.
Source
Optional package
Maturity
Released
Referral reporting
Outcome
Add privacy-bounded referral reporting with daily and lifetime counters.
Source
Optional package
Maturity
Beta
AI and agent delivery
Outcome
Track public-safe agent delivery with validation and application-owned boundaries.
Source
Roadmap
Maturity
Labs
Automation and integrations
Outcome
Track Laravel-native automation and integration extension points.
Source
Roadmap
Maturity
Labs
Experimentation and growth
Outcome
Track structured experimentation and measurement without presenting Labs work as shipped.
Source
Roadmap
Maturity
Labs
Trust, compliance, and operations
Outcome
Track application-owned diagnostics, governance, and recovery capability.
Source
Roadmap
Maturity
Labs
Customer self-service
Outcome
Track self-service capability built on the existing Laravel domain model.
Source
Roadmap
Maturity
Labs

Keep strict coding standards

Capell treats coding standards as product infrastructure: strict typing, PHPStan, Pest, Actions, data objects, and package-level checks keep CMS features clear to inspect before they reach editors or public pages.

Review reliability through one release workflow

Capell packages are assessed together as one Laravel system rather than solely as isolated downloads.

Automated checks catch repeatable failures, AI-assisted analysis challenges cross-package behaviour and unsupported claims, and an authorised human owns the decision for the exact artifact.

102 of 103 eligible first-party releases currently carry exact-release review evidence.

This counts recorded package reviews, not passed CI runs or externally verified engineering measurements. Review-method labels describe the standard; check dated execution evidence separately.

  1. Boringly explicit package boundaries

    Dependencies, extension points, permissions and public rendering responsibilities stay declared and inspectable.

    Automated AI-assisted
  2. Behaviour before boot tests

    Primary workflows, failure paths, authorisation, public output and lifecycle behaviour need meaningful tests.

    Automated AI-assisted
  3. A human owns the release

    AI can find missed interactions; it cannot issue the public verdict or inherit approval across changed code.

    AI-assisted Human sign-off
Questions

Before you adopt Capell

Does choosing Filament lock in the public frontend?
No. Filament is the admin UI. Public presentation remains in project-owned Blade, Livewire, or custom views. If the team rejects Filament for admin work, Capell is the wrong fit. Compare the Filament boundary
When are custom Filament resources still enough?
Use custom resources for a few stable editable screens. Capell fits when URLs, page types, layouts, previews, revisions, media, and publishing rules have become repeated CMS infrastructure. Compare custom Filament
What is forbidden in anonymous public output?
Public output must not expose model IDs, field paths, permissions, package internals, or signed editor URLs. Tests should prove that boundary for anonymous and non-admin requests. See public-output guardrails
How do we verify an upgrade before release?
Review the package changes, migrations, compatibility notes, and upgrade guide; then run the supported upgrade, diagnostics, focused tests, and application release checks. Read upgrade guidance
What must we plan if we leave or export?
Migration Assistant supports Capell page and site archives. Moving to another CMS requires a tested transformation, a URL and redirect plan, and separate database and media backups. Read the export-and-exit runbook
Loading footer