codenzia / filament-panel-base
Multi-panel architecture support with shared branding, dynamic colors, localization middleware, user moderation, and country/currency components for Filament v4.
Requires
- php: ^8.3
- filament/filament: ^4.0 || ^5.0
- spatie/laravel-package-tools: ^1.15.0
- spatie/laravel-settings: ^3.0
- spatie/laravel-translation-loader: ^2.8
Requires (Dev)
- bacon/bacon-qr-code: ^3.1
- laravel/pint: ^1.29
- laravel/socialite: ^5.0
- nunomaduro/collision: ^8.0
- orchestra/testbench: ^10.0 || ^11.0
- pestphp/pest: ^4.0
- pestphp/pest-plugin-arch: ^4.0
- pestphp/pest-plugin-laravel: ^4.0
- pragmarx/google2fa: ^9.0
Suggests
- bacon/bacon-qr-code: Required for the TwoFactor module — renders the enrolment QR code as inline SVG (composer require bacon/bacon-qr-code)
- bezhansalleh/filament-shield: Recommended for per-page authorisation on ManageAuthenticationSettings (subclass + HasPageShield trait — see README)
- lara-zeus/spatie-translatable: Required for Filament v4 translatable plugin integration (locale switcher, resource concerns)
- laravel/socialite: Required when enabling social login through the Auth module (->social(['google', ...]))
- livewire/livewire: Required to use the front-of-site Livewire auth pages (Register, Login, VerifyOtp, ...)
- pragmarx/google2fa: Required for the TwoFactor module — TOTP secret generation and code verification (composer require pragmarx/google2fa)
- propaganistas/laravel-phone: Required for libphonenumber-backed phone format validation in the auth module
- spatie/laravel-permission: Required for NotifiesAdmins role-based queries
- spatie/laravel-translatable: Required for translatable content support in Filament resources
This package is auto-updated.
Last update: 2026-06-06 05:19:36 UTC
README
Multi-panel architecture support for Filament v4 and v5 — shared branding, dynamic theme colours, CSS custom-property injection, localisation middleware, user moderation hooks, and country/currency form components. The architectural foundation behind every Codenzia Filament app (aqarkom, LarafilPos, snapcar, LaraFilCommerce).
Why this exists. Filament supports multiple panels out of the box, but each one defaults to its own brand, colour palette, locale handling, and middleware stack. Once you go beyond a single admin panel — say, an admin panel + a dashboard panel + a vendor panel — you end up duplicating provider code across three providers. This package extracts that shared layer into a
BasePanelProvideryou extend, plus a plugin that wires runtime branding from yourSettingsclass.
Try it live: A working integration is included in the Codenzia plugins demo at
/admin/demo/panel-base.
Features
BasePanelProvider— shared Filament panel scaffold; subclass per-panel for delta-only configuration.- Runtime branding — pull logo, app name, primary colour from a
Settingsclass (Spatie Settings). - Dynamic theme colours — Filament
->colors([...])reads from your settings at runtime. - CSS custom-property injection —
--primary-500etc. injected into the layout based on settings. - Localisation middleware — locale switching, RTL detection, fallback chain.
- User moderation — block/unblock/login-as scaffolding for support workflows.
- Country / currency components — Filament form components with full ISO data and flag rendering.
- Translation loader — DB-backed translations via
spatie/laravel-translation-loader. /demopage — drop-in Livewire landing page for sales demos and QA: password gate, auto-discovered model count tiles, one-click "login as" for every user, optional Standard/Demo seed buttons, footer with build date + dependency versions. Four levels of customization (config, view, section slots, full subclass).- Demo Settings admin page — view/rotate/share the
/demopassword from the panel without touching.env. Singletondemo_settingstable with encrypted password cast. - Analytics module — visitor tracking middleware, auth-event recording, AnalyticsPage with 9 widgets (visitors today, 30-day chart, top pages, slowest pages, error-rate sparkline, geo breakdown, device types, auth funnel, failed-login chart), date-range filter, tenant scoping, hourly rollup + nightly prune commands.
- Two-Factor Authentication module — TOTP enrolment via the profile slide-over, post-login challenge flow with intermediate session state, 8 single-use recovery codes hashed at rest, remember-device cookie, optional role-based mandatory enrolment middleware. Pluggable issuer/digits/period/window via fluent API.
- Sessions & Devices module — self-service "Devices & Sessions" tab listing every active session from Laravel's database driver, per-row revoke, "sign out everywhere else", new-device-login event for sending alert emails.
- Command Palette (Cmd-K) — global Cmd-K modal augmenting Filament's chrome with navigation jumps, a "Recent" group auto-populated from record-page views, and an extensible registry where consumer plugins push their own actions.
Requirements
| Dependency | Version |
|---|---|
| PHP | ^8.3 |
| Filament | ^4.0 || ^5.0 |
spatie/laravel-settings |
^3.0 |
spatie/laravel-translation-loader |
^2.8 |
Installation
composer require codenzia/filament-panel-base
Publish the config file:
php artisan vendor:publish --tag="filament-panel-base-config"
Admin navigation group
The package's admin pages — Analytics, Authentication settings, and Demo settings — are grouped in the sidebar under a single, configurable group (default System). Override it per app to fit your information architecture:
// config/filament-panel-base.php 'admin_navigation_group' => 'System', // or 'Settings', 'Admin', etc.
# or via .env FILAMENT_PANEL_BASE_ADMIN_NAV_GROUP="System"
Each page reads this at runtime via getNavigationGroup(), so a host app can place them wherever it likes without subclassing. (Demo settings sorts last within the group.)
Setup
1. Register the plugin
use Codenzia\FilamentPanelBase\FilamentPanelBasePlugin; public function panel(Panel $panel): Panel { return $panel ->plugins([ FilamentPanelBasePlugin::make() ->settingsUsing(fn () => app(\App\Settings\GeneralSettings::class)), ]); }
2. Extend BasePanelProvider
use Codenzia\FilamentPanelBase\Providers\BasePanelProvider; class AdminPanelProvider extends BasePanelProvider { public function panel(Panel $panel): Panel { $this->configureSharedSettings( $panel->default()->id('admin')->path('admin')->login() ); return $panel ->middleware($this->getSharedMiddleware()) ->authMiddleware([Authenticate::class]); } }
3. Register middleware
In bootstrap/app.php:
use Codenzia\FilamentPanelBase\Middleware\SetCountry; use Codenzia\FilamentPanelBase\Middleware\SetCurrency; ->withMiddleware(function (Middleware $middleware): void { $middleware->web([ SetCountry::class, SetCurrency::class, ]); })
4. Custom Theme (Tailwind v4) — Required
Filament v4's pre-built dist/theme.css only includes internal fi-* component styles. It does not include general Tailwind utility classes (size-5, grid, rounded-xl, p-5, etc.) needed by custom Blade views. You must create a Vite theme that imports Filament's source theme CSS and adds @source directives for your app and any packages with custom views.
Step 1: Create the theme CSS file
/* resources/css/filament/admin/theme.css */ @import "../../../../vendor/filament/filament/resources/css/theme.css"; @source '../../../../resources/views/filament'; @source '../../../../app/Filament'; @source '../../../../app/Providers/Filament/**/*.php'; @source '../../../../vendor/codenzia/*/src/**/*.php'; @source '../../../../vendor/codenzia/*/resources/views/**/*.blade.php';
Important: Import Filament's source
theme.css(fromresources/css/), not the pre-builtdist/theme.css. The source file includes@import 'tailwindcss' source(none)plus allfi-*component styles. Using bare@import 'tailwindcss'instead will give you Tailwind utilities but no Filament component styles, breaking the panel layout.
The @source directives tell Tailwind v4 which files to scan for class names. Add additional @source lines for any other packages that ship custom Blade views.
Step 2: Register the theme in your panel provider
$panel ->viteTheme('resources/css/filament/admin/theme.css');
Step 3: Add the theme to your Vite config
// vite.config.js laravel({ input: [ 'resources/css/app.css', 'resources/js/app.js', 'resources/css/filament/admin/theme.css', ], refresh: true, }),
Step 4: Build
npm run build
Without this setup, utility classes used in package Blade views (icons, grids, spacing, etc.) will not be compiled and your panel will render incorrectly — for example, SVG icons may appear at full size instead of their intended dimensions.
5. Frontend Theme (Optional)
The package includes a built-in theme system with 17 color presets and runtime CSS variable injection. This enables Tailwind utility classes like bg-brand-500 that update dynamically when the theme changes — no rebuild required.
Step 1: Add components to your layout <head>:
<x-filament-panel-base::dark-mode-script /> <x-filament-panel-base::theme-styles /> @vite(['resources/css/app.css', 'resources/js/app.js'])
<x-filament-panel-base::dark-mode-script /> prevents a flash of unstyled content by applying the dark class before first paint. <x-filament-panel-base::theme-styles /> injects CSS custom properties (--site-brand-*, --site-primary, etc.) into :root.
Step 2: Import the theme CSS in your resources/css/app.css:
@import "../../vendor/codenzia/filament-panel-base/resources/css/theme.css"; @import "tailwindcss";
This maps --color-brand-* to the runtime CSS variables via Tailwind v4's @theme directive, giving you utility classes like bg-brand-500, text-brand-600, etc.
Or publish the theme CSS for customization:
php artisan vendor:publish --tag=filament-panel-base-theme
Step 3: Implement ProvidesThemeColors on your settings class (optional):
use Codenzia\FilamentPanelBase\Contracts\ProvidesThemeColors; use Codenzia\FilamentPanelBase\Support\ThemePresets; class GeneralSettings extends Settings implements ProvidesThemeColors { public string $theme_preset = 'ocean_blue'; public string $primary_color = '#3b82f6'; // ... other color properties public function getThemeColors(): array { if ($this->theme_preset !== 'custom') { $preset = ThemePresets::get($this->theme_preset); if ($preset) { unset($preset['label']); return $preset; } } return [ 'primary_color' => $this->primary_color, // ... map all 15 color keys ]; } }
When no settings class implements ProvidesThemeColors, the package falls back to config('filament-panel-base.theme.preset') (default: ocean_blue).
Features
BasePanelProvider
Abstract panel provider that applies shared configuration to all panels:
- Brand name — resolved from settings class or
config('app.name') - Logo & favicon — resolved from settings via
getAppLogoUrl()/getAppFaviconUrl() - Dynamic colors — reads hex colors from settings (or
ProvidesThemeColorscontract) and converts viaColor::hex() - User menu — profile slideOver, role display, phone, email, cross-panel navigation
- Panel badge — "Administration" / "My Account" badge after the logo
- Visit Website button in the topbar
- Shared middleware stack — session, CSRF, Filament essentials
Panel configuration API
Call these methods on your BasePanelProvider subclass before configureSharedSettings():
class AdminPanelProvider extends BasePanelProvider { public function panel(Panel $panel): Panel { $this ->addTitleBadge('Administration', 'heroicon-o-shield-check', 'primary', showOnAuthForm: true) ->showVisitWebsite(label: 'Back to site') ->showLanguageDropdown() ->sidebarCollapseButtonPosition('right') ->sidebarIcon('heroicon-o-bars-3') ->sidebarSlideover() ->sidebarSearchable(); $this->configureSharedSettings( $panel->default()->id('admin')->path('admin')->login() ); return $panel->authMiddleware([Authenticate::class]); } }
Topbar
| Method | Default | Description |
|---|---|---|
showLanguageDropdown(bool $show = true) |
true |
Show or hide the locale switcher dropdown in the topbar. |
showVisitWebsite(bool $show = true, ?string $label = null) |
true |
Show or hide the "Visit Website" link button. Pass $label to override the translated default. |
addTitleBadge(string $label, ?string $icon = null, string $color = 'primary', bool $showOnAuthForm = true) |
— | Render a small colour-coded badge next to the logo. Accepts 'primary', 'success', 'warning', 'danger', 'info', or 'gray'. When $showOnAuthForm is true (default), the badge is also shown centred above the login and register forms. |
Sidebar
| Method | Default | Description |
|---|---|---|
sidebarCollapseButtonPosition(string $position) |
'left' |
'left' keeps Filament's default topbar button. 'right' replaces it with a pill button on the right edge of the sidebar. |
sidebarIcon(string $icon) |
— | Replace the default chevron with any Filament icon string (e.g. 'heroicon-o-bars-3'). Applies to both left and right button positions. |
sidebarSlideover(bool $enabled = true) |
true |
When enabled, the sidebar overlays the main content on desktop instead of pushing it. A dim backdrop is shown, matching Filament's mobile drawer behaviour. Call ->sidebarSlideover(false) to restore the default push layout. |
sidebarCollapseToIcons(bool $enabled = true) |
true |
When slideover is enabled, keep Filament's icon-only narrow bar when the sidebar is closed instead of sliding it fully off-screen. Users can still click nav icons without opening the full drawer. Call ->sidebarCollapseToIcons(false) to slide the sidebar fully off-screen instead. |
sidebarSearchable(bool $enabled = true) |
true |
Show a search input at the top of the sidebar navigation. Typing filters items client-side by matching labels; groups with no visible items are hidden automatically. The input is hidden when the sidebar is collapsed to icon-only mode. |
Note: Slideover mode is on by default. When it is active and no custom icon is set, the left-position button automatically uses
heroicon-o-bars-3(the mobile drawer icon) to signal drawer behaviour. The right-position pill button always uses the chevron SVG by default.
Theme System
The package ships 17 predefined color presets plus a custom option. Each preset defines 15 color keys covering primary, secondary, background, surface, text, status, border, and shadow colors.
Available presets: Ocean Blue, Forest Green, Sunset Orange, Royal Purple, Rose Garden, Modern Dark, Teal Breeze, Amber Gold, Slate Steel, Crimson Fire, Sky Light, Emerald Fresh, Indigo Classic, Pink Blossom, Warm Earth, Midnight Blue, Charcoal Noir.
ThemePresets API:
use Codenzia\FilamentPanelBase\Support\ThemePresets; ThemePresets::all(); // All 18 presets (17 + custom) ThemePresets::labels(); // ['ocean_blue' => 'Ocean Blue', ...] — for Select dropdowns ThemePresets::get('ocean_blue'); // Single preset array or null ThemePresets::defaults(); // Ocean Blue colors (the default) ThemePresets::colorKeys(); // ['primary_color', 'danger_color', ...] — all 15 keys
Blade components:
| Component | Purpose |
|---|---|
<x-filament-panel-base::theme-styles /> |
Injects CSS custom properties into :root using color-mix() for brand scale generation |
<x-filament-panel-base::dark-mode-script /> |
FOUC prevention — applies dark class before first paint |
The theme-styles component accepts an optional :colors prop. When omitted, it resolves colors automatically via FilamentPanelBasePlugin::make()->getThemeColors().
Color resolution order:
- Settings class implementing
ProvidesThemeColorsinterface - Config preset (
filament-panel-base.theme.preset) + color overrides - Ocean Blue defaults
CSS variables injected by <x-filament-panel-base::theme-styles />:
| Variable | Source |
|---|---|
--site-primary |
Primary brand color |
--site-primary-hover |
Primary hover state |
--site-brand-50 to --site-brand-900 |
Generated via color-mix() from primary |
--site-secondary, --site-background, --site-surface |
Semantic colors |
--site-text-primary, --site-text-secondary, --site-text-on-primary |
Text colors |
--site-success, --site-warning, --site-danger, --site-info |
Status colors |
--site-border, --site-shadow |
UI element colors |
--site-surface-page, --site-surface-page-dark |
Auth-layout body background (light / dark) |
--site-surface-card, --site-surface-card-dark |
Auth-card background (light / dark) |
--site-surface-input, --site-surface-input-dark |
Auth-input background (light / dark) |
--site-surface-border, --site-surface-border-dark |
Auth-card ring + input border (light / dark) |
Theming auth pages
The package ships login / register / forgot-password / reset-password / verify-email / verify-OTP views (under resources/views/livewire/auth). They are not publishable — consuming projects don't override the Blade. Instead, recolor them with runtime CSS variables that <x-filament-panel-base::theme-styles /> writes onto :root.
The auth views use two Tailwind v4 color scales backed by these variables:
primary-{50..900}— buttons, focus rings, links. Mirrorsbrand-*; both point at--site-primary/--site-primary-hoverand the--site-brand-*scale.surface-card/surface-input/surface-border(each with a-darkcompanion) — card chrome, input background, input/card border.
Overridable --site-* knobs:
| Variable | Default | Where it shows up |
|---|---|---|
--site-primary |
#3b82f6 |
Submit button, focus rings, links |
--site-primary-hover |
#2563eb |
Button hover state |
--site-surface-page |
#f9fafb |
Auth layout body background (light) |
--site-surface-page-dark |
#111827 |
Auth layout body background (dark) |
--site-surface-card |
#ffffff |
Card background (light) |
--site-surface-card-dark |
#1f2937 |
Card background (dark) |
--site-surface-input |
#ffffff |
Input background (light) |
--site-surface-input-dark |
#111827 |
Input background (dark) |
--site-surface-border |
#d1d5db |
Input border + card ring (light) |
--site-surface-border-dark |
#374151 |
Input border + card ring (dark) |
Example — recolor without touching Blade:
/* resources/css/app.css */ @import "../../vendor/codenzia/filament-panel-base/resources/css/theme.css"; @import "tailwindcss"; :root { --site-primary: #16a34a; /* green-600 — buttons, focus rings */ --site-primary-hover: #15803d; /* green-700 */ --site-surface-card: #f9fafb; /* gray-50 — card panel */ --site-surface-input: #ffffff; --site-surface-border: #e5e7eb; /* gray-200 — softer hairline */ }
These overrides also apply if you set the matching keys (primary_color, primary_hover_color, surface_page_color, surface_page_dark_color, surface_card_color, surface_card_dark_color, surface_input_color, surface_input_dark_color, surface_border_color, surface_border_dark_color) on a settings class implementing ProvidesThemeColors — <x-filament-panel-base::theme-styles /> writes them onto :root for you.
Auth throttling
Brute-force protection for the Livewire auth pages lives inside the components, not on the route. Filament/Livewire form submissions POST to /livewire/update, which bypasses route-level middleware — so any throttle middleware on the auth pages would never see the credential submission. The package handles this via the ThrottlesAuthAttempts trait used by Login, Register, ForgotPassword, ResetPassword, VerifyOtp, and VerifyEmailNotice::resend.
Three buckets are checked on every attempt:
- Per-IP, per-minute — catches one-IP rapid-fire.
- Per-identifier, per-minute — catches distributed credential stuffing against one account from many IPs.
- Per-IP, per-day — long-window backstop; not cleared on successful login.
Both windows pull their limits from AuthenticationSettings::throttle_per_minute (default 5) and throttle_per_day (default 50). When a budget is exhausted, the component throws a ValidationException with the auth.throttle_rate_limited message routed to the form's error bag — no extra UI work needed.
Identifiers (emails, phones, user ids, OTP targets) are HMAC'd with the app key before being used as cache keys, so raw addresses never land in the cache store.
The ThrottleAuth middleware still ships, but it's scoped to the OAuth redirect/callback routes only (where every hit triggers external API work). Don't attach it to Livewire-backed routes — it has no effect there and only causes confusion.
Apply the same pattern to a custom auth flow:
use Codenzia\FilamentPanelBase\Auth\Concerns\ThrottlesAuthAttempts; class CustomLogin extends \Livewire\Component { use ThrottlesAuthAttempts; public function submit(): void { $this->validate([...]); $this->ensureNotRateLimited('custom-login', $this->identifier); if (! Auth::attempt(...)) { $this->hitRateLimiter('custom-login', $this->identifier); $this->addError('identifier', __('...')); return; } $this->clearRateLimiter('custom-login', $this->identifier); // ... } }
Social login (OAuth)
The Auth module ships a complete social-login flow built on laravel/socialite: redirect + callback routes, find-or-create-or-link logic, multi-identity storage in a social_accounts table, profile UI for linking/unlinking, and three email-conflict policies to defeat account-takeover.
What the plugin gives you:
GET /oauth/{provider}/redirectandGET /oauth/{provider}/callbackroutes, throttled and gated by bothservices.{provider}.client_idand the admin's enable toggle.- A
social_accountstable (one row per linked identity per user) with encrypted access/refresh tokens. FindsOrCreatesFromSocialitetrait that resolves a Socialite payload to a User by either matchingprovider+provider_id, applying the configured email-conflict policy, or creating a fresh user.- Connect/disconnect profile UI (
<livewire:filament-panel-base::auth.manage-social-accounts />). SocialAccountMapping(pre-persistence, mutable) andSocialUserLinked(post-persistence) events for app-level customisation.- Inline brand-icon Blade component (
<x-filament-panel-base::social-provider-icon :provider="$p" />) for the common providers — no external icon dependency.
What the host app provides:
- The
laravel/socialitepackage and provider credentials. - A User model that implements
SupportsSocialLoginand uses the default trait. - The
social_accountsmigration, published from the plugin.
Setup
-
Install Socialite:
composer require laravel/socialite
-
Publish the migrations and run them:
php artisan vendor:publish --tag=filament-panel-base-auth-migrations php artisan migrate
This adds the
social_accountstable. If you previously used the legacy single-provider columns (users.provider/users.provider_id), the publish also drops a one-shot data migration that copies them into the new table and removes the legacy columns. The migration is idempotent — safe on fresh installs. -
Make your User model social-aware:
use Codenzia\FilamentPanelBase\Auth\Concerns\FindsOrCreatesFromSocialite; use Codenzia\FilamentPanelBase\Auth\Contracts\SupportsSocialLogin; class User extends Authenticatable implements SupportsSocialLogin { use FindsOrCreatesFromSocialite; }
The trait provides
findOrCreateFromSocialite(),linkSocialAccount(), and thesocialAccounts()HasMany relation. Override any of them on the model if you need different behaviour. -
Configure provider credentials in
config/services.php— standard Socialite:'google' => [ 'client_id' => env('GOOGLE_CLIENT_ID'), 'client_secret' => env('GOOGLE_CLIENT_SECRET'), 'redirect' => '/oauth/google/callback', ], 'github' => [ 'client_id' => env('GITHUB_CLIENT_ID'), 'client_secret' => env('GITHUB_CLIENT_SECRET'), 'redirect' => '/oauth/github/callback', ],
The callback paths above match the routes the plugin registers — set them verbatim in your OAuth app dashboards too.
-
Enable providers via the plugin:
use Codenzia\FilamentPanelBase\FilamentPanelBasePlugin; FilamentPanelBasePlugin::make() ->withAuthentication(fn ($auth) => $auth ->social(['google', 'github']) ->socialEmailLinking('require_login') // safest default ->socialTrustVerifiedEmail(true) );
Or flip them at runtime from the admin's auth settings page — fluent overrides win for the lifetime of the request, settings persist.
Email-conflict policies
When a user signs in via a provider whose email matches an existing local user that has not previously linked this provider, the plugin needs to decide what to do. Pick one with ->socialEmailLinking($policy) or the auth.social_email_linking setting:
| Policy | Behaviour | Use when |
|---|---|---|
require_login (default) |
Refuse the auto-link. Redirect to login with a hint: "Sign in with your original method, then connect this provider from your profile." | Public-facing apps. Recommended. Defeats the account-takeover vector where an attacker spins up a provider account using a victim's email. |
trust_verified |
Link only when both sides assert verified email: the user has email_verified_at set and the provider's payload includes email_verified: true. |
Mid-trust apps that want fewer support tickets without inviting takeover. |
auto |
Unconditional link, matching the historical Laravel/Socialite tutorial pattern. | Avoid in production. Only safe when the provider universe is fully trusted (e.g. SSO inside a closed org). |
Linking and unlinking from the profile page
Mount the manage component on whatever profile/settings page your app uses:
<livewire:filament-panel-base::auth.manage-social-accounts />
It lists each enabled provider as either "Connected" (with a Disconnect button) or "Available" (with a Connect button). Disconnect is automatically blocked when removing the last sign-in method would lock the user out — they have to set a password first.
Customising attribute mapping
Subscribe to SocialAccountMapping to mutate what gets persisted before either the User or the SocialAccount row is written:
use Codenzia\FilamentPanelBase\Auth\Events\SocialAccountMapping; Event::listen(SocialAccountMapping::class, function (SocialAccountMapping $event): void { $event->userAttributes['avatar_url'] = $event->socialUser->getAvatar(); $event->userAttributes['locale'] = $event->socialUser->getRaw()['locale'] ?? null; });
$event->userAttributes is only persisted when $event->creatingUser === true. $event->socialAccountAttributes is persisted every time a social_accounts row is written (signup, link, or re-link).
For post-persistence side effects (welcome emails, audit logging) use SocialUserLinked instead — the linked flag is true on the first link/signup and false on returning sign-in.
Registration policies
AuthenticationSettings controls who may create an account. Mix and match — the controls compose:
| Goal | How |
|---|---|
| Open — anyone can register | registration_mode = 'open' (default) |
| Moderated — admin must approve | registration_mode = 'moderated' → new users land pending; EnsureUserApproved blocks login until approved, AccountApprovedNotification emails them |
| Closed — no self-signup | Don't enable Filament's ->registration() page; admins create/invite accounts |
Domain-restricted — only @acme.com (and subdomains) |
allowed_email_domains = ['acme.com'] (empty = any domain) |
| No throwaway emails | disposable_email_blocking = true (default) |
The email-domain allowlist is enforced by the AllowedEmailDomain validation rule and is look-alike safe (notacme.com does not satisfy an acme.com allowlist). Set it three ways — admin Authentication settings page, the fluent API, or an env fallback:
FilamentPanelBasePlugin::make() ->withAuthentication(fn ($auth) => $auth ->moderation() // require admin approval ->allowedEmailDomains(['acme.com']) // staff-only signup (+ subdomains) ->disposableEmailBlocking() // reject throwaway providers );
# config fallback, used before settings are migrated / when the DB is unavailable PANEL_ALLOWED_EMAIL_DOMAINS="acme.com,acme.io"
Auth settings page (admin UI)
The plugin ships a Filament page that surfaces every AuthenticationSettings field — registration mode, identifier, verification, sign-up email-domain allowlist, OTP driver/lifetime, social providers, email-linking policy, throttle limits — grouped into sections so admins don't need to edit DB rows directly.
Authorisation (REQUIRED — fail-closed default)
This page controls authentication policy for the whole app, so it is fail-closed by default: ManageAuthenticationSettings::canAccess() returns false. Registering the page on a panel does not expose it — a host-side subclass with its own authorisation check is required.
With bezhansalleh/filament-shield:
namespace App\Filament\Pages; use BezhanSalleh\FilamentShield\Traits\HasPageShield; use Codenzia\FilamentPanelBase\Auth\Filament\Pages\ManageAuthenticationSettings; class AuthSettings extends ManageAuthenticationSettings { use HasPageShield; }
With a simple Gate/ability check (no shield):
namespace App\Filament\Pages; use Codenzia\FilamentPanelBase\Auth\Filament\Pages\ManageAuthenticationSettings; class AuthSettings extends ManageAuthenticationSettings { public static function canAccess(): bool { return auth()->user()?->can('manage-auth-settings') ?? false; } }
Then register your subclass:
FilamentPanelBasePlugin::make() ->withFilamentAuthSettingsPage(\App\Filament\Pages\AuthSettings::class);
Calling ->withFilamentAuthSettingsPage() with no argument is intentionally a no-op for end users: the page registers on the panel, but canAccess() still returns false. Always subclass — even in trusted internal panels — so the security check is local to your repo and visible in code review.
Already maintaining your own settings page (the deprecated RegistrationSettings-backed pattern)? See Legacy: RegistrationSettings (deprecated) below for the step-by-step swap.
Middleware
| Middleware | Description |
|---|---|
SetLocale |
Detects locale from session/cookie, validates against ProvidesLocales provider, then propagates the chosen code onto Carbon, CarbonImmutable, Number::useLocale(), and the spatie-translatable active locale so the whole formatting stack moves in lockstep with the UI |
SetCountry |
Auto-detects country from IP using geo API, stores in session |
SetCurrency |
Sets active currency from country relationship or session |
EnsureUserApproved |
Blocks suspended/pending users (requires HasModerationStatus contract) |
ThrottleAuth |
Per-IP rate limit for native HTTP auth routes (OAuth redirect/callback). Livewire-backed pages use the ThrottlesAuthAttempts trait instead — see Auth throttling. |
Localisation
The package treats locale handling as a layered concern — middleware, routing, model traits, vendor overrides — instead of one big switcher. Everything below works without filament/translations or laravel-lang/lang installed (those packages improve translation coverage but are not required for the mechanics to function).
Declaring locales
// config/filament-panel-base.php 'locale' => [ 'available' => ['en', 'ar', 'fr'], // codes the user can switch to 'detection_order' => ['session', 'cookie', 'config'], 'routes' => [ 'enabled' => true, // ships `locale.switch` named route 'prefix' => '', 'middleware' => ['web'], ], ],
available doubles as the allowlist for both SetLocale middleware and the shipped locale.switch controller — only codes listed here can become the active locale, so a malformed URL like /locale/zz is silently ignored instead of crashing.
For dynamic locales pulled from the database, register a class implementing Codenzia\FilamentPanelBase\Contracts\ProvidesLocales and reference it via locale.provider. The contract returns ['ar' => ['native' => 'العربية', 'dir' => 'rtl', 'flag' => 'sa'], ...].
locale.switch route
Codenzia\FilamentPanelBase\Http\Controllers\LocaleController::switch backs the locale.switch named route. The bundled <x-filament-panel-base::locale-switcher> view points at it by default (switchRoute prop), so the dropdown works the moment the plugin is registered — no host wiring. To replace it with your own controller, set locale.routes.enabled = false and register a Route::get(...)->name('locale.switch') yourself.
Carbon, Number, and translatable content stay in sync
After App::setLocale($locale), the SetLocale middleware also calls:
Carbon::setLocale($locale)andCarbonImmutable::setLocale($locale)— fixes->diffForHumans(),->translatedFormat(...), and date diffs that would otherwise stay in the previously-set process locale.Number::useLocale($locale)(when present) — fixesNumber::currency(),Number::ordinal(),Number::percentage(), etc.session(['spatie_translatable_active_locale' => $locale])— switches the active locale forspatie/laravel-translatablecontent so translatable Filament fields default to the same language as the UI.
No host code is required to opt in; the propagation runs on every request that hits the panel.
RTL auto-toggle
Filament v4's base layout reads __('filament-panels::layout.direction') to populate <html dir="...">. The package ships minimal layout.direction overrides for the canonical RTL locales under the filament-panels namespace:
ar(Arabic)he(Hebrew)fa(Persian / Farsi)ur(Urdu)
Declaring any of these in locale.available flips the entire panel to RTL the moment a user picks the locale — sidebars on the right, modal close buttons on the left, navigation chevrons mirrored. No additional packages required.
If you maintain your own list of RTL locales (e.g. a niche dialect), pair it with SetLocale::isRtlLocale(string $code): bool, which exposes the same allowlist (ar, he, fa, ur, ps, sd, yi, ku, dv) used by the config-fallback dropdown payload.
Validation translations
Adding ar to locale.available is half the story — Laravel's validator looks for lang/ar/validation.php in the host's resources, and if it doesn't exist, every validation error falls back to English. Two strategies:
-
Production-quality translations.
composer require laravel-lang/langships community-maintained translations for 70+ locales. Runphp artisan lang:add arand you're done. -
Quick scaffold. When you need a starting template (or the locale isn't covered by
laravel-lang/lang):php artisan filament-panel-base:scaffold-validation # uses config('filament-panel-base.locale.available') php artisan filament-panel-base:scaffold-validation ar fr de # explicit codes php artisan filament-panel-base:scaffold-validation ar --force # overwrite existing
Seeds each target with Laravel's bundled English
validation.php, ready to translate. Skips files that already exist unless--forceis passed.
Per-user preferred locale
For notifications, mark your User model with the HasPreferredLocale trait:
use Codenzia\FilamentPanelBase\Concerns\HasPreferredLocale; use Illuminate\Contracts\Translation\HasLocalePreference; use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Notifications\Notifiable; class User extends Authenticatable implements HasLocalePreference { use HasPreferredLocale; use Notifiable; // Optional — defaults to the 'locale' column. protected string $preferredLocaleAttribute = 'ui_lang'; }
Laravel's NotificationSender wraps every Notification::send($user, ...) in withLocale($user->preferredLocale(), ...) whenever the notifiable implements HasLocalePreference. Approval emails, password resets, OTP messages all dispatch in the user's chosen language with no per-notification code. The trait falls back to config('app.locale') when the column is null/empty so a missing preference never short-circuits the wrap mid-send.
Legacy: RegistrationSettings (deprecated)
Codenzia\FilamentPanelBase\Settings\RegistrationSettings is the legacy settings group (registration.* keys) with only two fields — registration_mode and require_email_verification. It is deprecated since 2.0 and retained solely for back-compat with apps that import the class directly.
New code should target AuthenticationSettings instead — same two fields plus everything else the auth module exposes (OTP driver, social providers, email-linking policy, throttle limits, …). The admin UI is the in-plugin Auth settings page — no more hand-rolled "Manage Registration Settings" pages.
Migrating an existing app:
- Add
->withFilamentAuthSettingsPage(\App\Filament\Pages\AuthSettings::class)(ordiscoverPages) for the in-plugin page — see the Auth settings page section for the shield-subclass pattern. - Delete your hand-rolled page + Blade view.
- Stop seeding the
registration.*group —panel-base's settings migration already seedsauth.*defaults. - Update any code that reads
app(RegistrationSettings::class)->registration_modeto read fromAuthenticationSettingsinstead. - If you rely on Filament Shield permissions, rename the page permission (e.g.
View:ManageRegistrationSettings→View:AuthSettings) via a one-off migration so existing roles carry over without a manual re-seed.
The deprecated class will be removed in the next major release.
Contracts
Implement these interfaces on your models/settings to integrate with the package:
use Codenzia\FilamentPanelBase\Contracts\ProvidesThemeColors; class GeneralSettings extends Settings implements ProvidesThemeColors { public function getThemeColors(): array { // Return array with keys like 'primary_color', 'danger_color', etc. } }
use Codenzia\FilamentPanelBase\Contracts\HasModerationStatus; class User extends Authenticatable implements HasModerationStatus { public function isSuspended(): bool { /* ... */ } public function isPending(): bool { /* ... */ } }
use Codenzia\FilamentPanelBase\Contracts\ProvidesLocales; class Language extends Model implements ProvidesLocales { public static function getActive(): array { /* ... */ } }
use Codenzia\FilamentPanelBase\Contracts\ProvidesCountries; class Country extends Model implements ProvidesCountries { public function scopePublished(Builder $query): Builder { /* ... */ } public function currency() { /* ... */ } }
use Codenzia\FilamentPanelBase\Contracts\ProvidesCurrencies; class Currency extends Model implements ProvidesCurrencies { public function getCodeAttribute(): string { /* ... */ } public function getSymbolAttribute(): string { /* ... */ } }
Traits
| Trait | Description |
|---|---|
HasProfileSlideOver |
Profile-editing slideOver action in the user menu with vertical tabs (Personal Info + Change Password) |
NotifiesAdmins |
Sends notifications to admin-role users and optionally the content author |
HasContactValidation |
Shared validation rules for lead capture forms (name, phone, email, WhatsApp) |
HasPreferredLocale |
Implements Laravel's HasLocalePreference so notifications auto-dispatch in the user's chosen language — see Localisation |
HasProfileSlideOver
Used by BasePanelProvider to add a profile-editing slideOver to the Filament user menu. Includes name, email, and password fields out of the box. Override methods in your panel provider to add project-specific fields:
| Method | Purpose |
|---|---|
getProfilePersonalInfoComponents() |
Form fields for the "Personal Information" tab |
getProfilePasswordComponents() |
Form fields for the "Change Password" tab |
getProfileFormTabs() |
Customise tabs (add new ones, reorder, etc.) |
getProfileFormData() |
Data to fill the form (override to include relationships) |
saveProfileData(array $data) |
Persist form data (override to handle media sync, etc.) |
Example — adding an avatar and phone field:
use Codenzia\FilamentMedia\Forms\MediaPickerField; use Codenzia\FilamentPanelBase\Forms\Components\PhoneInput; class UserPanelProvider extends BasePanelProvider { protected function getProfileFormData(): array { $data = parent::getProfileFormData(); $data['media_avatar'] = filament()->auth()->user()->images()->first()?->getKey(); return $data; } protected function getProfilePersonalInfoComponents(): array { return [ ...parent::getProfilePersonalInfoComponents(), PhoneInput::make('phone')->label(__('Phone'))->countries(/* ... */), MediaPickerField::make('media_avatar')->label(__('Avatar'))->imageOnly(), ]; } protected function saveProfileData(array $data): void { $user = filament()->auth()->user(); if (array_key_exists('media_avatar', $data)) { $user->syncMediaByIds($data['media_avatar'] ? [$data['media_avatar']] : []); unset($data['media_avatar']); } parent::saveProfileData($data); } }
Blade Components
Form fields (Livewire-bound via @entangle):
<x-filament-panel-base::country-select :countries="$countries" wire-model="country_id" /> <x-filament-panel-base::country-code-select :countries="$countries" wire-model="country_code" /> <x-filament-panel-base::phone-input :countries="$countries" country-code-model="country_code" phone-model="whatsapp" />
phone-input combines a country code dropdown and phone number input into a single bordered group with a searchable dropdown. It accepts the same country collection as the other components and binds to two separate Livewire properties.
| Prop | Default | Description |
|---|---|---|
:countries |
(required) | Eloquent collection of Country models (must have code, phone_code, name) |
country-code-model |
'country_code' |
Livewire property for the selected dial code |
phone-model |
'whatsapp' |
Livewire property for the phone number |
:default |
null |
Fallback country code (e.g. '+962') |
placeholder |
'501234567' |
Input placeholder |
Navbar switchers (pair with SetCountry/SetCurrency/SetLocale middleware):
<x-filament-panel-base::country-switcher :mode="$countryDropdownMode" /> <x-filament-panel-base::currency-switcher /> <x-filament-panel-base::locale-switcher :locales="$locales" :currentLocale="$currentLocale" /> <x-filament-panel-base::dark-mode-toggle />
The switchers read view-shared data from middleware ($availableCountries, $currentCountry, $availableCurrencies, $currentCurrency, $currentCurrencyMode) and require routes named country.switch, currency.switch, and locale.switch in the consuming app.
Shared switcher props:
| Prop | Default | Description |
|---|---|---|
align |
'end' |
Horizontal anchor of the dropdown: 'start' (left in LTR) or 'end' (right in LTR). |
relative |
true |
Whether the wrapper element is a CSS positioning context. Set to false to let the dropdown position relative to a parent relative container instead. |
Mobile menu example — use :relative="false" with align="start" so all dropdowns anchor to a shared relative container, preventing overflow on narrow screens:
<div class="relative flex items-center gap-3"> <x-filament-panel-base::country-switcher :relative="false" align="start" /> <x-filament-panel-base::currency-switcher :relative="false" align="start" /> <x-filament-panel-base::locale-switcher :relative="false" align="start" :locales="$locales" :currentLocale="$currentLocale" /> </div>
Filament Form Components
Reusable form fields for Filament v4 panels. Both use Filament's native CSS classes (fi-input-wrp, fi-input) for full theme compatibility — any custom panel styling automatically applies.
CountrySelect
A Select field with flag icons beside each country name. Extends Filament\Forms\Components\Select with allowHtml(), searchable(), and preload() pre-configured.
From relationship (stores the country ID):
use Codenzia\FilamentPanelBase\Forms\Components\CountrySelect; CountrySelect::make('country_id') ->relationship('country', 'name') ->required() ->live()
The related model must have a code column with the ISO country code (e.g. jo, sa). To use a different column:
CountrySelect::make('country_id') ->codeAttribute('iso_code') ->relationship('country', 'name')
From array (keys are ISO codes, stored as value):
CountrySelect::make('country') ->countries(['jo' => 'Jordan', 'sa' => 'Saudi Arabia'])
From array (keys are IDs, with explicit code):
CountrySelect::make('country_id') ->countries([ 1 => ['name' => 'Jordan', 'code' => 'jo'], 2 => ['name' => 'Saudi Arabia', 'code' => 'sa'], ])
From closure (lazy-loaded):
CountrySelect::make('country_id') ->countries(fn () => Country::published() ->get() ->mapWithKeys(fn ($c) => [$c->id => ['name' => $c->name, 'code' => strtolower($c->code)]]) ->toArray())
PhoneInput
A compound field with a country code dropdown (flags + dial code) and a phone number input. Stores the combined value as a single string (e.g. +962501234567). Uses Filament's fi-input-wrp wrapper with a non-inline prefix for the country code section.
use Codenzia\FilamentPanelBase\Forms\Components\PhoneInput; PhoneInput::make('phone') ->label(__('Phone')) ->countries(fn (): array => Country::published() ->whereNotNull('phone_code') ->orderBy('order') ->get() ->map(fn (Country $c): array => [ 'code' => strtolower($c->code), 'phone_code' => $c->phone_code, 'name' => $c->name, ]) ->toArray())
Each country in the array must have code (ISO, lowercase), phone_code (e.g. +962), and name.
Default country code:
PhoneInput::make('phone') ->countries($countries) ->defaultCountryCode('+962')
Placeholder & validation:
PhoneInput::make('phone') ->countries($countries) ->placeholder('7XXXXXXXX') ->required() ->unique(ignoreRecord: true)
The field supports disabled(), readOnly(), live(), and standard Filament validation rules.
Flag Icons
This package bundles flag-icons CSS and SVGs for country flag display. On Filament panels, the CSS is auto-injected via @filamentStyles. For frontend layouts, add a <link> tag:
<link rel="stylesheet" href="{{ asset('css/codenzia/filament-panel-base/flag-icons.css') }}">
Publish the SVG assets:
php artisan filament:assets php artisan vendor:publish --tag=filament-panel-base-assets
Note: The bundled flag-icons.css is minified with very long lines. If your IDE's spell checker warns about it (e.g. cSpell's "line length greater than 20000"), add it to your ignore list in .vscode/settings.json:
{ "cSpell.ignorePaths": ["**/flag-icons.css"] }
Important: The CSS class prefix is flag (not the upstream fi) to avoid collision with Filament's own .fi-* class namespace. Usage:
<span class="flag flag-sa"></span> <!-- Saudi Arabia (4:3) --> <span class="flag flags flag-gb"></span> <!-- UK (1:1 square) -->
ColorUtils
Static utility class for color manipulation:
use Codenzia\FilamentPanelBase\Support\ColorUtils; ColorUtils::hexToRgb('#3b82f6'); // [59, 130, 246] ColorUtils::hexToRgbString('#3b82f6'); // 'rgb(59, 130, 246)' ColorUtils::hexToRgba('#3b82f6', 0.5); // 'rgba(59, 130, 246, 0.5)' ColorUtils::isLightColor('#ffffff'); // true ColorUtils::getContrastColor('#3b82f6'); // '#ffffff'
Configuration
// config/filament-panel-base.php return [ 'panels' => ['admin', 'dashboard'], 'admin_role' => 'super_admin', 'user_model' => \App\Models\User::class, 'locale' => [ 'provider' => null, // class implementing ProvidesLocales 'available' => ['en'], 'detection_order' => ['session', 'cookie', 'config'], ], 'country' => [ 'auto_detect' => true, 'default_id' => null, 'model' => null, // class implementing ProvidesCountries 'geo_api' => 'https://ipapi.co/{ip}/json/', 'cache_ttl' => 1440, ], 'currency' => [ 'model' => null, // class implementing ProvidesCurrencies 'virtual_usd' => true, ], 'contact_validation' => [ 'require_whatsapp' => false, 'allow_email_alternative' => true, ], 'settings_class' => null, // class with branding properties 'colors' => [ 'primary' => '#3b82f6', 'secondary' => '#6366f1', 'danger' => '#ef4444', 'warning' => '#f59e0b', 'success' => '#10b981', 'info' => '#06b6d4', ], 'theme' => [ 'preset' => 'ocean_blue', // any ThemePresets key 'colors' => [], // override individual color keys ], 'branding' => [ // Render a small "Powered by Codenzia" footer on every panel page // and via <x-filament-panel-base::powered-by /> on non-Filament pages. // Set CODENZIA_BRANDING=false in .env to hide. 'powered_by_enabled' => env('CODENZIA_BRANDING', true), ], ];
Branding
A subtle "Powered by Codenzia" credit line is rendered on every Filament panel page automatically — no configuration needed. The package registers a PanelsRenderHook::FOOTER hook in its service provider that injects the credit into the panel chrome below the page content.
Where it appears
- Every page inside a Filament panel (admin, dashboard, customer panels): admin index, resource list/create/edit/view, custom pages, settings pages, login / register / password-reset auth pages.
Where it does not appear automatically
-
Pages that live outside a Filament panel — your front-of-site Livewire/Blade pages, marketing routes, raw public endpoints. For those, drop the matching Blade component into your root layout:
<x-filament-panel-base::powered-by />Same wording, same styling, same opt-out via
CODENZIA_BRANDING.
Hiding the credit
Set the env var in .env:
CODENZIA_BRANDING=false
The hook checks this on every request, so no cache clear is needed when toggling. Useful when an app graduates from a Codenzia-controlled demo to a customer-owned deployment that wants its own branding.
Styling
The default markup uses Tailwind utilities (text-xs, text-gray-400 dark:text-gray-600) and respects your panel's primary color via hover:text-primary-500. To restyle, publish the package's powered-by.blade.php component or override the render hook in your own AppServiceProvider:
use Filament\Support\Facades\FilamentView; use Filament\View\PanelsRenderHook; use Illuminate\Support\Facades\Blade; // In AppServiceProvider::boot() FilamentView::registerRenderHook( PanelsRenderHook::FOOTER, fn (): string => Blade::render('<your custom footer markup here>') );
The most recently-registered hook wins, so a host override replaces the package default cleanly.
Translatable Content (Optional)
The package provides automatic integration with spatie/laravel-translatable and lara-zeus/spatie-translatable for Filament v4. When both packages are installed, BasePanelProvider auto-registers the SpatieTranslatablePlugin on every panel — no manual plugin registration needed.
When to use this
Use this integration when your project has translatable database content (e.g. product names, category descriptions, page content stored in multiple languages). This is different from Laravel's built-in __() / trans() localization which translates static UI strings.
Good fit: A real estate site where property names, descriptions, and locations are stored in both English and Arabic.
Not needed: A single-language app, or an app that only translates UI labels via language files.
Step 1: Install the packages
composer require spatie/laravel-translatable lara-zeus/spatie-translatable
Step 2: Configure available locales
In your config/filament-panel-base.php, set the locales your content supports:
'locale' => [ 'provider' => \App\Models\Language::class, 'available' => ['en', 'ar'], // ← used by the translatable plugin 'detection_order' => ['session', 'cookie', 'config'], ],
The locale.available array is passed to SpatieTranslatablePlugin::defaultLocales() automatically. That's all the panel-level setup required — no need to register the plugin yourself.
Step 3: Make your models translatable
Add the HasTranslations trait and declare which columns are translatable:
use Spatie\Translatable\HasTranslations; class Category extends Model { use HasTranslations; public array $translatable = ['name', 'description']; }
Translatable columns must be json type in the database. Create a migration if converting existing columns:
Schema::table('categories', function (Blueprint $table) { $table->json('name')->change(); $table->json('description')->nullable()->change(); });
Step 4: Make your Filament resources translatable
Add the Translatable concern to each resource class:
use LaraZeus\SpatieTranslatable\Resources\Concerns\Translatable; class CategoryResource extends Resource { use Translatable; protected static ?string $model = Category::class; // ... }
Step 5: Add the locale switcher to resource pages
Add the Translatable concern and LocaleSwitcher action to each resource page:
For ManageRecords pages:
use LaraZeus\SpatieTranslatable\Resources\Pages\ManageRecords\Concerns\Translatable; use LaraZeus\SpatieTranslatable\Actions\LocaleSwitcher; class ManageCategories extends ManageRecords { use Translatable; protected static string $resource = CategoryResource::class; protected function getHeaderActions(): array { return [ LocaleSwitcher::make(), Actions\CreateAction::make()->slideOver(), ]; } }
For ListRecords pages:
use LaraZeus\SpatieTranslatable\Resources\Pages\ListRecords\Concerns\Translatable; use LaraZeus\SpatieTranslatable\Actions\LocaleSwitcher; class ListCategories extends ListRecords { use Translatable; protected static string $resource = CategoryResource::class; protected function getHeaderActions(): array { return [ LocaleSwitcher::make(), Actions\CreateAction::make(), ]; } }
Customising the integration
Override registerTranslatablePlugin() in your panel provider to customise the behaviour:
class AdminPanelProvider extends BasePanelProvider { protected function registerTranslatablePlugin(Panel $panel): void { // Custom locales per panel $panel->plugin( \LaraZeus\SpatieTranslatable\SpatieTranslatablePlugin::make() ->defaultLocales(['en', 'ar', 'fr']) ->persist() ); } }
To disable the integration for a specific panel, override with an empty method:
protected function registerTranslatablePlugin(Panel $panel): void { // This panel does not need translatable content }
Translation Manager UI (Optional)
The package includes a built-in Translation Manager that lets you view, edit, and scan all __() / trans() language strings from the admin panel — no file editing required. Translations are stored in the database via spatie/laravel-translation-loader and override file-based translations at runtime.
When to use this
Use this when you need a non-developer-friendly UI to manage static language files (e.g. lang/en/messages.php, lang/ar.json). This is different from spatie/laravel-translatable which handles database content.
Key features:
- Codebase scanner — automatically finds all
__(),trans(),@lang(),Lang::get()calls - Dynamic locales — reads available languages from your
ProvidesLocalesprovider (no hardcoded config) - Per-language workflow — access translations from your Language resource's action group, scoped to a single locale
- Configurable scanner — scan extra file types (
js,ts,vue) and functions ($t,i18n.t) - DB overrides — database translations take precedence over file translations, with caching
Step 1: Publish migrations and config
php artisan filament-panel-base:enable-translations
This publishes the spatie/laravel-translation-loader migration and config.
Step 2: Run migrations
php artisan migrate
Step 3: Configure the translation model
In config/translation-loader.php, point the model to the panel-base Translation model:
'model' => Codenzia\FilamentPanelBase\Models\Translation::class,
Step 4: Opt in per panel
Add ->withTranslations() to FilamentPanelBasePlugin::make() in the panel(s) where you want the translation routes registered:
->plugins([ FilamentPanelBasePlugin::make() ->withTranslations() ->settingsUsing(fn () => app(\App\Settings\GeneralSettings::class)), ])
The Translations resource is hidden from sidebar navigation by default. It is designed to be accessed from a Language resource (see Step 5). Panels without ->withTranslations() are unaffected.
Step 5: Add to your Language resource
Add the Manage Translations action to your Language resource's record actions and optionally the Scan action to the page header:
use Codenzia\FilamentPanelBase\Filament\Resources\TranslationResource; // In your LanguageResource table(): ->recordActions([ Actions\ActionGroup::make([ Actions\EditAction::make()->slideOver(), TranslationResource::manageAction(), // opens translations scoped to this language Actions\DeleteAction::make(), ]), ]) // In your ManageLanguages page getHeaderActions() (optional): TranslationResource::scanHeaderAction(),
When the user clicks Manage Translations on a language, the translations page opens scoped to that locale — the table shows that language's text and the edit form only shows the relevant textarea.
Step 6: Scan your codebase for translation keys
php artisan translations:scan
This scans your project for all __(), trans(), @lang() calls and populates the database with initial values from your existing language files. Re-run whenever you add new translation keys.
You can also scan from the admin UI using the Scan button in the Translations page header.
Customising the scanner
Override scan paths, file extensions, and translation functions via config:
// config/filament-panel-base.php 'translations' => [ 'navigation_group' => 'Settings', 'navigation_sort' => 11, 'navigation_icon' => 'heroicon-o-language', 'scan_paths' => null, // null = [app_path(), resource_path('views')] 'scan_extensions' => ['php'], // add 'js', 'ts', 'vue' for frontend files 'scan_functions' => [], // extra function names, e.g. ['$t', 'i18n.t'] ],
The scanner always matches __() plus the PHP-specific grouped functions (trans(), @lang(), Lang::get(), etc.). The scan_functions config adds extra function names for JSON-style translation calls in other languages.
Demo Page (Optional)
A drop-in /demo Livewire page for sales demos, QA, and reviewer walkthroughs. Auto-discovers your app/Models/ classes for a stats grid, lists every user with a one-click "login as" button (super_admins blocked by default), shows optional Standard/Demo seed buttons when those seeders exist, and renders a footer with build date and PHP/Laravel/Filament versions. The page is gated by a single shared password sourced from .env (and optionally a DB row — see the Demo Settings page below).
When to use this
You're shipping a Filament app to internal QA, sales prospects, or auditors and want a single URL that introduces every demo account and seed dataset without typing credentials.
Enable per host
In .env:
FILAMENT_PANEL_BASE_DEMO_ENABLED=true APP_DEMO_PAGE_PWD=replace-with-a-random-string
That's it — the package registers a GET /demo route automatically when enabled. Defaults to web middleware, the bundled standalone layout (Tailwind via CDN so it renders regardless of your CSS build state), and the included Livewire component.
Always set
APP_DEMO_PAGE_PWD. With no password configured,/demostays locked by default (the gate refuses every submission). See Empty-password behavior below if you need to opt into the legacy auto-unlock on fresh installs.
Four customization levels
The defaults work for a typical app. When you need more, lift up only the layer that's wrong — you don't have to fork the whole page.
1. Config-driven stat list. Override the auto-discovered model counts:
// config/filament-panel-base.php 'demo' => [ 'stats' => [ ['model' => \App\Models\Property::class, 'label' => 'Listings', 'icon' => 'heroicon-o-home'], ['model' => \App\Models\Inquiry::class, 'label' => 'Inquiries', 'icon' => 'heroicon-o-envelope'], ], // Or keep auto-discovery and just hide noisy models: 'exclude_models' => [\App\Models\PivotJunk::class, 'PasswordReset'], ],
2. Publishable view. Customize the markup without writing PHP:
php artisan vendor:publish --tag=filament-panel-base-views
Then edit resources/views/vendor/filament-panel-base/livewire/demo/page.blade.php.
3. Named Livewire section slots. Plug your own Livewire components into the page chrome without forking it. Four slots: before_stats, after_stats, before_users, after_users.
// config/filament-panel-base.php 'demo' => [ 'sections' => [ 'before_stats' => \App\Livewire\AqarkomCountryFilterSection::class, 'after_stats' => \App\Livewire\AqarkomMarketSnapshot::class, ], ],
The page renders @livewire($component) at each slot — your component dispatches Livewire events that the page (or its subclass) listens to via #[On(...)].
4. Whole-component swap. Subclass DemoPage and override collectStats(), collectUsers(), or canLogInAs():
// app/Livewire/DemoPage.php namespace App\Livewire; use Codenzia\FilamentPanelBase\Livewire\Demo\DemoPage as BaseDemoPage; class DemoPage extends BaseDemoPage { protected function collectUsers(): array { // Limit to seeded demo accounts, decorate with domain counts return User::whereIn('email', ['superadmin@example.test', 'agent@example.test']) ->withCount(['properties', 'inquiries']) ->get() ->map(fn ($u) => /* ... return the expected shape ... */) ->all(); } protected function canLogInAs(Model $user): bool { // Hard email allowlist — replaces a bespoke POST /demo/login throttle gate return in_array($user->email, self::DEMO_EMAILS, true); } }
Then wire it in AppServiceProvider::boot():
config(['filament-panel-base.demo.component' => \App\Livewire\DemoPage::class]);
The route resolves this lazily via $this->app->booted(), so your host config wins over the package default.
Seed buttons
The page conditionally renders Standard / Demo seed buttons when the corresponding class exists in your app. Override the seeder map if your classes have different names:
'demo' => [ 'seeders' => [ 'standard' => 'Database\\Seeders\\StandardSeeder', 'demo' => 'Database\\Seeders\\DemoSeeder', ], ],
Both buttons trigger migrate:fresh + the configured seeder, then auto-login the first admin (or the one identified by demo.admin_email).
Demo Settings Page (Optional)
A Filament admin page (ManageDemoSettings, registered under the Settings navigation group) that lets admins view, regenerate, and share the /demo password without touching .env. Backed by a singleton demo_settings table with an encrypted password cast and a last_used_at timestamp updated on every successful gate unlock.
When to use this
You're sharing /demo with prospects, you have multiple apps and don't want to memorize a different .env value for each one, and you want a "rotate now" button rather than redeploying when a password leaks.
Enable per panel
php artisan vendor:publish --tag=filament-panel-base-demo-migrations php artisan migrate
Then opt in via the plugin:
FilamentPanelBasePlugin::make() ->withDemoSettingsPage()
Password resolution order
DemoPage::expectedPassword() resolves in this order:
demo_settings.password(DB row, encrypted cast) if the table exists and the value is setAPP_DEMO_PAGE_PWDenv varnull→ gate stays locked (page never renders the demo content without a password)
The .env fallback means a fresh install isn't locked out before the migration runs, and hosts that never set up the DB row keep the env-only behavior.
Empty-password behavior
When expectedPassword() resolves to null or '', /demo stays locked by default — the password form is shown and unlock() rejects every submission. /demo is never public unless a password is explicitly set.
If you actively want the old behavior (auto-unlock when no password is configured — handy on fresh local installs), opt in:
FILAMENT_PANEL_BASE_DEMO_ALLOW_EMPTY=true
Or per app:
// config/filament-panel-base.php 'demo' => [ 'allow_empty_password' => true, ],
Leave it off for staging/production deployments. The flag exists for local development convenience, not as a way to expose /demo without a password.
CLI: demo:password
For SSH access or when the admin UI isn't enabled, manage the password from the command line. Writes go to the demo_settings DB row (encrypted cast) — same source the gate reads from first.
# Show the current password and its source (DB / env / unset) php artisan demo:password # Generate a fresh 16-char random password, save it, print it php artisan demo:password --regenerate # Set the password to a specific value php artisan demo:password --set='your-chosen-value'
Requires the demo_settings migration to have run (php artisan vendor:publish --tag=filament-panel-base-demo-migrations && php artisan migrate); read-only php artisan demo:password falls back to the env var when the table doesn't exist.
Analytics
Visitor + auth-event tracking with a ready-to-mount AnalyticsPage. Off by default — call ->withAnalytics() on the plugin to turn it on.
Quick start
// AppServiceProvider::boot — global config FilamentPanelBasePlugin::make() ->withAnalytics() // sensible defaults ->withFilamentAnalyticsPage(); // mounts /admin/analytics // then: php artisan migrate
That's it. The three analytics tables (visits, visits_daily, auth_events) and the settings rows are auto-loaded — no vendor:publish required. Visit /admin/analytics to see the dashboard.
Plugin API
->withAnalytics(fn ($a) => $a ->trackVisits() // default true ->trackAuthEvents() // default true ->ipAnonymization('truncate') // 'none' | 'truncate' | 'hash' ->retainRawDays(30) // raw visits pruned after N days ->retainAggregatedDays(365) // visits_daily kept N days ->botFilter() // tag bot UAs is_bot=true (excluded from widgets) ->writeQueue('analytics') // dispatch RecordVisitJob to this queue (null = sync) )
What gets recorded
visits (every page view, retained retain_raw_days):
id, session_id, user_id, tenant_id, tenant_type, panel, route_name, path, method, status, referrer_host, country_code, ip_hash, device_type, browser, platform, is_bot, duration_ms, created_at.
auth_events (small rows, retained retain_aggregated_days):
type is one of login.success, login.failed, logout, register, otp.requested, otp.verified, social.login, moderation.*, password.reset, two_factor.enabled, two_factor.disabled, two_factor.failed, two_factor.recovery_used.
Scheduled commands
Boot automatically when runningInConsole():
| Command | Cadence | Job |
|---|---|---|
filament-panel-base:analytics:rollup |
Hourly, no overlap | Rebuild visits_daily buckets for the affected dates. |
filament-panel-base:analytics:prune |
Daily at 03:15, no overlap | Chunk-delete visits rows older than retain_raw_days, visits_daily + auth_events older than retain_aggregated_days. |
Tenant scoping
Widgets and the rollup are tenant-scoped via filament()->getTenant(). If your panel uses Filament tenancy, each tenant's admins see only their own visits/auth events.
Subclassing the page for Shield / custom access
// Your subclass namespace App\Filament\Admin\Pages; use BezhanSalleh\FilamentShield\Traits\HasPageShield; use Codenzia\FilamentPanelBase\Analytics\Filament\Pages\AnalyticsPage as Base; class AnalyticsPage extends Base { use HasPageShield; } // In your panel ->withFilamentAnalyticsPage(\App\Filament\Admin\Pages\AnalyticsPage::class)
Privacy + GDPR
ip_anonymization='truncate'(default) zeroes the last octet (IPv4) / last 80 bits (IPv6) before hashing — pseudonymous but not reversible.ip_anonymization='hash'stores onlysha256(raw_ip).ip_anonymization='none'stores the raw IP (hashed for the column type) — use only if your legal posture allows it.- Retention is enforced by the prune command; nothing leaks indefinitely once
retain_raw_dayspasses.
Two-Factor Authentication
TOTP enrolment + post-login challenge with recovery codes. Off by default — call ->withTwoFactor() on the plugin to turn it on.
Install dependencies
composer require pragmarx/google2fa bacon/bacon-qr-code
Both are listed as suggest: — install them only if you use 2FA. The services throw a clear RuntimeException if missing.
Quick start
// 1. AppServiceProvider::boot FilamentPanelBasePlugin::make() ->withTwoFactor() ->withFilamentTwoFactorChallengePage(); // optional: render challenge inside panel chrome // 2. User model use Codenzia\FilamentPanelBase\TwoFactor\Concerns\HasTwoFactorAuthentication; class User extends Authenticatable { use HasTwoFactorAuthentication; } // 3. Profile slide-over tab — extend your PanelProvider use Codenzia\FilamentPanelBase\TwoFactor\Concerns\HasTwoFactorProfileTab; class AdminPanelProvider extends BasePanelProvider { use HasTwoFactorProfileTab; protected function getProfileFormTabs(): array { return [ ...parent::getProfileFormTabs(), $this->getTwoFactorProfileTab(), ]; } } // 4. Run migrations — adds the 3 columns to your users table php artisan migrate
Plugin API
->withTwoFactor(fn ($tf) => $tf ->issuer('Acme Inc.') // shown in the authenticator app entry ->digits(6) // 6, 7, or 8 (Google Authenticator wants 6) ->period(30) // TOTP step in seconds (RFC default 30) ->acceptanceWindow(1) // accept ±N step codes (clock-skew tolerance) ->recoveryCodeCount(8) // 8 single-use codes per user ->requireForRoles(['super_admin']) // enforce via RequireTwoFactor middleware ->rememberDevice(true, days: 30) // long-lived cookie to skip repeat challenges )
How the post-login challenge works
- User submits credentials →
LoginLivewire validates them. - If the user has
hasTwoFactorEnabled() === true, the credentials pass butAuth::login()is not called. Instead the user id + remember flag are stashed in the session undercodenzia.two_factor_challenge. - The user is redirected to
route('two-factor.challenge')(/two-factor-challenge). - The user enters either a 6-digit TOTP code or a 10-10 recovery code. The challenge component verifies via the trait's
verifyTwoFactorCode(), callsAuth::login(), regenerates the session, and redirects to the intended URL. - If they tick "Trust this device for 30 days", a HMAC cookie keyed on
(user_id, secret, app_key)is queued. Regenerating the 2FA secret or disabling 2FA invalidates it automatically.
Mandatory enrolment for specific roles
->withTwoFactor(fn ($tf) => $tf->requireForRoles(['super_admin', 'finance'])); // Wire the middleware in your panel $panel->authMiddleware([ Authenticate::class, \Codenzia\FilamentPanelBase\TwoFactor\Http\Middleware\RequireTwoFactor::class, ]);
RequireTwoFactor redirects matching users to the challenge page on every request until they enrol. It needs spatie/laravel-permission's hasAnyRole() on your user model; without it, the middleware fails open (no lockout).
Database columns
The auto-loaded migration adds these to your users table (idempotent via Schema::hasColumn guards — safe to re-run, safe alongside an existing Fortify install since the names match exactly):
| Column | Type | Notes |
|---|---|---|
two_factor_secret |
text nullable |
Encrypted at rest via accessor. |
two_factor_recovery_codes |
text nullable |
Encrypted JSON of bcrypt hashes. |
two_factor_confirmed_at |
timestamp nullable |
Null until the user verifies one code. |
Events
| Event | Fired when |
|---|---|
TwoFactorEnabled |
User confirmed enrolment with a valid TOTP code. |
TwoFactorDisabled |
User turned 2FA off (only fires if it was enabled). |
RecoveryCodeUsed |
A recovery code was consumed during a challenge. Send a "we noticed" email here. |
TwoFactorChallengeFailed |
Invalid code submitted at challenge. Auto-persisted as auth_events.type = two_factor.failed for dashboards. |
Sessions & Devices
Self-service active-session list with per-row revoke and "sign out everywhere else". Off by default — call ->withSessionManagement() on the plugin to turn it on.
Requirement: database session driver
This module reads Laravel's sessions table directly. SESSION_DRIVER=database is required. With any other driver the profile tab degrades to a friendly "configure database sessions to see this" notice — nothing crashes, but the list stays empty.
# .env SESSION_DRIVER=database php artisan session:table # if you don't already have one php artisan migrate
Quick start
// 1. AppServiceProvider::boot FilamentPanelBasePlugin::make()->withSessionManagement(); // 2. Profile slide-over tab — extend your PanelProvider use Codenzia\FilamentPanelBase\Sessions\Concerns\HasSessionManagementProfileTab; class AdminPanelProvider extends BasePanelProvider { use HasSessionManagementProfileTab; protected function getProfileFormTabs(): array { return [ ...parent::getProfileFormTabs(), $this->getSessionManagementProfileTab(), ]; } }
Plugin API
->withSessionManagement(fn ($s) => $s ->notifyOnNewDevice() // fire NewDeviceLogin on unseen IP+UA fingerprints ->idleThresholdMinutes(15) // sessions older than this show as "last active X min ago" ->allowLogoutOtherDevices() // expose the "sign out everywhere else" button )
NewDeviceLogin event
A DetectNewDeviceLogin listener subscribes to Illuminate\Auth\Events\Login. On every successful login it computes sha256(ip|user_agent) and looks for a matching existing row in the sessions table for that user. If none is found, it dispatches Codenzia\FilamentPanelBase\Sessions\Events\NewDeviceLogin($user, $ipAddress, $userAgent) — wire your own listener to email the user.
// In your EventServiceProvider or a Listener use Codenzia\FilamentPanelBase\Sessions\Events\NewDeviceLogin; Event::listen(NewDeviceLogin::class, function (NewDeviceLogin $event): void { Mail::to($event->user)->send(new NewDeviceLoginMail($event)); });
The fingerprint is intentionally coarse (IP + UA, not browser cookies) so private-mode browsing from a known device doesn't trigger false positives.
What the user sees
For each active session: device-type icon (desktop/mobile/tablet), browser + OS, IP address, "active now" or "last active X minutes ago", a "Current" badge on the row matching session()->getId(), and a Revoke button (or Sign out for the current row).
If there's more than one row, a "Sign out everywhere else" button appears at the top of the list.
Command Palette (Cmd-K)
A global Cmd-K modal that augments Filament's chrome with navigation jumps and recently viewed records. Off by default — call ->withCommandPalette() on the plugin to turn it on.
Quick start
FilamentPanelBasePlugin::make()->withCommandPalette(); php artisan migrate // creates command_palette_recent_views table
Once enabled, pressing Cmd+K (macOS) or Ctrl+K anywhere on a Filament page opens a search modal. Navigation entries for every Filament resource and page on the current panel appear by default.
Plugin API
->withCommandPalette(fn ($c) => $c ->hotkeyLabel('⌘K') // displayed hint (rendering only) ->recentViewLimit(15) // max items kept per (user, panel) ->trackRecentViews(true) // auto-record record-page views )
Recent-record auto-tracking
Wired into Filament::serving(). On every served request, if the current Livewire controller is a Filament resource record page (has getRecord() + getResource()), a row is upserted into command_palette_recent_views for the authenticated user. The recorder is best-effort and silently swallows any failure.
Pruning happens at write-time per (user, panel) tuple — no scheduled job required.
Adding your own actions
Implement CommandPaletteContributor (or pass a callable / raw array) and register with the singleton registry:
use Codenzia\FilamentPanelBase\CommandPalette\CommandPaletteRegistry; use Codenzia\FilamentPanelBase\CommandPalette\Contracts\CommandPaletteContributor; use Codenzia\FilamentPanelBase\CommandPalette\Data\CommandPaletteAction; class QuickActionsContributor implements CommandPaletteContributor { public function actions(?string $query = null): iterable { return [ new CommandPaletteAction( id: 'action:export-csv', label: 'Export users to CSV', url: route('admin.users.export'), description: 'Download a snapshot of every user as CSV.', icon: 'heroicon-o-arrow-down-tray', group: 'Actions', keywords: ['download', 'spreadsheet'], ), ]; } } // AppServiceProvider::boot app(CommandPaletteRegistry::class)->register(new QuickActionsContributor);
Or, for a one-shot:
app(CommandPaletteRegistry::class)->register(fn () => [ new CommandPaletteAction(id: 'quick', label: 'Quick action', url: '/x'), ]);
Actions are deduped by id, scored against the query (label prefix > label substring > haystack substring), and capped at 50 entries per modal render.
Keyboard
Cmd+K/Ctrl+K— toggle the modal↑/↓— move selectionEnter— open the selected actionEsc— close
All handled by Alpine.js inside the modal — no JS bundle changes.
Plugin API
FilamentPanelBasePlugin::make() // Resolve settings via closure ->settingsUsing(fn () => app(GeneralSettings::class)) // Or by class name ->settingsClass(GeneralSettings::class) // Enable the translation manager UI for this panel (opt-in) ->withTranslations() // Register the Demo Settings admin page (opt-in; requires the // demo_settings migration — see "Demo Settings Page" above) ->withDemoSettingsPage() // Analytics module — visitor tracking, auth events, AnalyticsPage ->withAnalytics() ->withFilamentAnalyticsPage() // Two-Factor Authentication — opt-in TOTP + post-login challenge ->withTwoFactor() ->withFilamentTwoFactorChallengePage() // Active session listing in the profile slide-over (requires SESSION_DRIVER=database) ->withSessionManagement() // Cmd-K command palette mounted on every Filament page in this panel ->withCommandPalette() // Get resolved theme colors (used internally by <x-filament-panel-base::theme-styles />) FilamentPanelBasePlugin::make()->getThemeColors(); // Returns: ['primary_color' => '#3b82f6', 'danger_color' => '#ef4444', ...]
Requirements
- PHP 8.3+
- Laravel 12+
- Filament v4
spatie/laravel-settings^3.0 (required, forAuthenticationSettings/RegistrationSettings)spatie/laravel-permission(optional, forNotifiesAdminstrait)spatie/laravel-translatable+lara-zeus/spatie-translatable(optional, for translatable database content)spatie/laravel-translation-loader^2.8 (bundled — activate with->withTranslations()for translation manager UI)
License
This package is dual-licensed:
- MIT License — Free for open source projects under an OSI-approved license.
- Commercial License — Required for proprietary/commercial projects. Visit codenzia.com for details.
See LICENSE.md for full terms.