Skip to content

Build An Extension End To End

This tutorial builds a small capell-app/announcement-bar package. It adds one settings group, one admin control page, one frontend render hook, cache invalidation for public output, and focused tests.

Use this shape for real packages: keep package logic in Actions/Data, register through Capell extension points, and prove the public output is safe.

The package should:

  • store an announcement message and enabled flag;
  • expose those settings in Admin;
  • render a small public banner above the page content;
  • avoid exposing admin/editor state in anonymous HTML;
  • invalidate public pages when the announcement changes;
  • be installable through Composer or Marketplace metadata.
announcement-bar/
├── composer.json
├── capell.json
├── README.md
├── config/announcement-bar.php
├── database/settings/create_announcement_bar_settings.php
├── resources/lang/en/announcement-bar.php
├── resources/views/frontend/banner.blade.php
├── src/Actions/ResolveAnnouncementBarAction.php
├── src/Admin/AnnouncementBarAdminBridge.php
├── src/Data/AnnouncementBarData.php
├── src/Filament/Pages/AnnouncementBarSettingsPage.php
├── src/Providers/AnnouncementBarServiceProvider.php
├── src/Settings/AnnouncementBarSettings.php
└── tests/Feature/AnnouncementBarFrontendTest.php

Keep the package small until this works. Add migrations, jobs, widgets, or Marketplace metadata only when the package really needs them.

flowchart LR
Composer["Composer installs package"] --> Manifest["capell.json is read"]
Manifest --> Provider["AnnouncementBarServiceProvider boots"]
Provider --> Settings["Settings class + schema registered"]
Provider --> Admin["AdminBridge registered"]
Provider --> Frontend["RenderHookRegistry callback registered"]
Provider --> Cache["Cache invalidation rule registered"]
Admin --> Editor["Admin edits message"]
Editor --> Cache
Cache --> Public["Next public render uses fresh data"]
{
"name": "capell-app/announcement-bar",
"type": "library",
"autoload": {
"psr-4": {
"Capell\\AnnouncementBar\\": "src/"
}
},
"extra": {
"laravel": {
"providers": [
"Capell\\AnnouncementBar\\Providers\\AnnouncementBarServiceProvider"
]
}
}
}

The namespace should match the package. Do not place app-specific models or host project classes in reusable packages.

{
"manifestVersion": 3,
"name": "capell-app/announcement-bar",
"label": "Announcement Bar",
"description": "Adds a site-wide public announcement banner.",
"kind": "package",
"providers": {
"runtime": [
"Capell\\AnnouncementBar\\Providers\\AnnouncementBarServiceProvider"
]
},
"surfaces": ["admin", "frontend"],
"settings": {
"groups": ["announcement-bar"]
},
"product": {
"group": "Marketing",
"tier": "standard",
"bundle": "content-tools"
}
}

The manifest is package metadata, not runtime logic. Runtime registration belongs in providers.

Use a settings class for persistent state and a Data object for the value passed to views.

<?php
declare(strict_types=1);
namespace Capell\AnnouncementBar\Data;
use Spatie\LaravelData\Data;
final class AnnouncementBarData extends Data
{
public function __construct(
public bool $enabled,
public string $message,
) {}
}
<?php
declare(strict_types=1);
namespace Capell\AnnouncementBar\Actions;
use Capell\AnnouncementBar\Data\AnnouncementBarData;
use Capell\AnnouncementBar\Settings\AnnouncementBarSettings;
use Lorisleiva\Actions\Concerns\AsObject;
final class ResolveAnnouncementBarAction
{
use AsObject;
public function handle(): AnnouncementBarData
{
$settings = app(AnnouncementBarSettings::class);
return new AnnouncementBarData(
enabled: (bool) $settings->enabled,
message: trim((string) $settings->message),
);
}
}

The Blade view receives AnnouncementBarData. It should not read settings, query models, or inspect the current admin user.

<?php
declare(strict_types=1);
namespace Capell\AnnouncementBar\Providers;
use Capell\Admin\Facades\CapellAdmin;
use Capell\AnnouncementBar\Actions\ResolveAnnouncementBarAction;
use Capell\AnnouncementBar\Admin\AnnouncementBarAdminBridge;
use Capell\AnnouncementBar\Settings\AnnouncementBarSettings;
use Capell\Core\Support\Packages\AbstractPackageServiceProvider;
use Capell\Core\Support\Settings\SettingsSchemaRegistry;
use Capell\Frontend\Enums\RenderHookLocation;
use Capell\Frontend\Support\Render\RenderHookRegistry;
use Illuminate\Contracts\View\View;
use Spatie\LaravelPackageTools\Package;
final class AnnouncementBarServiceProvider extends AbstractPackageServiceProvider
{
public static string $name = 'announcement-bar';
public static string $packageName = 'capell-app/announcement-bar';
public function configurePackage(Package $package): void
{
$package
->name(self::$name)
->hasConfigFile('announcement-bar')
->hasViews('announcement-bar')
->hasTranslations()
->hasMigration('create_announcement_bar_settings');
}
public function packageBooted(): void
{
$this->registerSettings();
$this->registerAdmin();
$this->registerFrontend();
}
private function registerSettings(): void
{
app(SettingsSchemaRegistry::class)
->registerSettingsClass('announcement-bar', AnnouncementBarSettings::class);
}
private function registerAdmin(): void
{
CapellAdmin::registerAdminBridge(self::$packageName, AnnouncementBarAdminBridge::class);
}
private function registerFrontend(): void
{
app(RenderHookRegistry::class)->register(
RenderHookLocation::BodyStart,
function (): View|string|null {
$announcement = ResolveAnnouncementBarAction::run();
if (! $announcement->enabled || $announcement->message === '') {
return null;
}
return view('announcement-bar::frontend.banner', [
'announcement' => $announcement,
]);
},
);
}
}

If a package has separate install/runtime/admin/frontend providers, keep provider buckets in capell.json aligned with those responsibilities. Do not register Filament resources from a frontend-only provider.

<?php
declare(strict_types=1);
namespace Capell\AnnouncementBar\Admin;
use Capell\Admin\Contracts\Bridges\AdminBridge;
use Capell\Admin\Data\AdminBridgeContextData;
use Capell\Admin\Facades\CapellAdmin;
use Capell\Admin\Support\Bridges\AdminBridgeRegistrar;
use Capell\AnnouncementBar\Filament\Pages\AnnouncementBarSettingsPage;
final class AnnouncementBarAdminBridge implements AdminBridge
{
public function register(AdminBridgeRegistrar $registrar, AdminBridgeContextData $context): void
{
CapellAdmin::registerExtensionPage($context->packageName, AnnouncementBarSettingsPage::class);
}
}

Use an AdminBridge when a package contributes more than one admin surface or needs a predictable package-owned registration point.

@if ($announcement->enabled && $announcement->message !== '')
<aside class="announcement-bar">
{{ $announcement->message }}
</aside>
@endif

This view is intentionally boring. It renders public copy only. It must not include model IDs, field paths, admin URLs, authoring selectors, permissions, or package diagnostics.

If settings changes affect every public page, invalidate the public page cache from the settings save Action or package settings page. Prefer an Action that updates settings and clears the exact cache scope it owns.

For model-owned output, register model dependencies through CacheInvalidationRegistry::registerDependency(ModelClass::class, ...). Do not build ad hoc cache keys in Blade.

it('registers the admin bridge', function (): void {
CapellCore::forcePackageInstalled('capell-app/announcement-bar');
expect(CapellAdmin::getAdminBridgeRegistry()->classes('capell-app/announcement-bar'))
->toContain(AnnouncementBarAdminBridge::class);
});
it('renders only public announcement markup', function (): void {
app(AnnouncementBarSettings::class)->fill([
'enabled' => true,
'message' => 'Open weekend hours',
])->save();
$html = $this->get('/')->getContent();
expect($html)->toContain('Open weekend hours')
->not->toContain('filament')
->not->toContain('signed')
->not->toContain('field_path')
->not->toContain('data-capell-editor');
});
it('returns disabled data when message is empty', function (): void {
app(AnnouncementBarSettings::class)->fill([
'enabled' => true,
'message' => ' ',
])->save();
$data = ResolveAnnouncementBarAction::run();
expect($data->enabled)->toBeTrue()
->and($data->message)->toBe('');
});

Run the narrow package suite first:

Terminal window
vendor/bin/pest packages/announcement-bar/tests --configuration=phpunit.xml
  • Package appears in composer show capell-app/announcement-bar.
  • capell.json validates and uses manifest version 3.
  • Provider registers package-owned settings, admin, frontend, and cache behavior only.
  • Admin UI strings use translations.
  • Public output passes anonymous and non-admin safety tests.
  • README links to package docs, extension points, config, tests, and troubleshooting.