bezhansalleh/filament-plugin-essentials
Essential traits for Filament plugin development that handle the boilerplate for you, so you can focus on shipping real features faster. Supports Filament 4.x and 5.x.
Install:
composer require bezhansalleh/filament-plugin-essentials
Requires PHP 8.2+ and Filament 4.x or 5.x. There is no service provider to publish, no config file, no migration — this is a pure traits package.
Where to look first: the Plugin & Resource Trait Mapping table in the README. Everything in this package is a paired contract: a Plugin\HasX trait on your plugin class must be matched by a Resource\HasX trait on each resource the plugin ships. If you skim anything, skim that table.
The first use case — you have built a plugin that ships UserResource and PostResource, and you want panel users to rename the navigation group, swap icons, and control global-search limits without editing your source:
// src/UserPlugin.php
use BezhanSalleh\PluginEssentials\Concerns\Plugin;
use Filament\Contracts\Plugin;
use Filament\Facades\Filament;
class UserPlugin implements Plugin
{
use Plugin\HasNavigation;
use Plugin\HasLabels;
use Plugin\HasGlobalSearch;
use Plugin\WithMultipleResourceSupport; // only if you ship >1 resource
public static function make(): static { return app(static::class); }
public static function get(): ?static
{
return Filament::getPlugin('user-plugin');
}
public function getId(): string { return 'user-plugin'; }
}
// src/Resources/UserResource.php
use BezhanSalleh\PluginEssentials\Concerns;
use Filament\Resources\Resource;
class UserResource extends Resource
{
use Concerns\Resource\HasNavigation;
use Concerns\Resource\HasLabels;
use Concerns\Resource\HasGlobalSearch;
protected static ?string $model = User::class;
public static function getEssentialsPlugin(): ?UserPlugin
{
return UserPlugin::get();
}
}
That is genuinely the whole minimum. Once those traits are in place, consumers get:
$panel->plugins([
UserPlugin::make()
->navigationGroup('Administration')
->navigationIcon('heroicon-o-users')
->globalSearchResultsLimit(30),
]);
Without the package, that same surface area is roughly 15 static properties plus a config file plus a resolver class. Start by porting one trait (HasNavigation is the highest-value) and confirming the fluent chain reaches the resource before adding the rest.
Ship sensible defaults so consumers only override what they care about. The getPluginDefaults() array is where a plugin author encodes opinion:
protected function getPluginDefaults(): array
{
return [
// Applies to every resource in the plugin
'navigationGroup' => 'User Management',
'navigationIcon' => 'heroicon-o-users',
'globalSearchResultsLimit' => 25,
// Per-resource overrides take precedence over the globals above
'resources' => [
PostResource::class => [
'navigationSort' => 20,
'globalSearchResultsLimit' => 10,
'recordTitleAttribute' => 'slug',
],
],
];
}
Reach for getDefault{Property}() when the answer depends on the resource class. It beats the array, receives the resource class, and keeps the defaults readable:
protected function getDefaultModelLabel(?string $resourceClass = null): string
{
return match ($resourceClass) {
UserResource::class => 'User',
TeamResource::class => 'Team',
default => 'Item',
};
}
Give consumers per-resource control when your plugin ships more than one resource. This is the pattern that makes WithMultipleResourceSupport worth including:
UserPlugin::make()
->forResource(UserResource::class)
->navigationLabel('Members')
->globalSearchResultsLimit(50)
->forResource(PostResource::class)
->navigationLabel('Articles')
->modelLabel('Article')
->globalSearchResultsLimit(5),
The forResource() call returns a scoped proxy, so the chain reads top-to-bottom and each block is independently overridable. Keep your example panel provider in the README showing exactly this, because consumers will not discover forResource() on their own.
Lean on closures for anything that is not constant. Navigation badges that count unread records, labels that vary by role, search limits that differ per tenant:
UserPlugin::make()
->navigationBadge(fn () => (string) User::whereNull('email_verified_at')->count())
->navigationBadgeColor(fn () => User::whereNull('email_verified_at')->exists() ? 'warning' : 'success')
->navigationLabel(fn () => auth()->user()->isAdmin() ? 'All Users' : 'My Team'),
Understand (and document) the resolution ladder, because every support question you get will be "why is my override ignored?" The order is:
->forResource(X::class)->navigationIcon(...)->navigationIcon(...)getDefault{Property}() → getPluginDefaults()['resources'][X][prop] → legacy getPluginDefaults()[X][prop] → getPluginDefaults()[prop]Steps 1–2 live in the panel provider; 3 lives in your plugin; 4 lives in the resource and the panel. When debugging, walk the ladder in that order rather than guessing.
Keep your own resource statics as the last-resort fallback, not the primary source. If UserResource declares protected static ?string $navigationIcon = 'heroicon-o-user'; and the plugin's defaults omit the key, the static wins. That is a useful safety net for plugin authors but it also means a stale static will silently shadow nothing — it only applies at tier 4.
Wire the plugin on every resource, not just the root one. getEssentialsPlugin() is the lookup bridge and must be present on every resource using a Resource\* trait. Return UserPlugin::get() (nullable, already resolved from the panel) rather than app(UserPlugin::class), since plugins are instantiated and configured by the panel, not the container.
Defaults are keyed by property name, not setter name. This is the single most common mistake:
// WRONG — silently ignored
'navigationGroup' => ... // ✅ correct
'registerNavigation' => true, // ❌ should be shouldRegisterNavigation
'globallySearchable' => true, // ❌ should be isGloballySearchable
'titleCaseModelLabel' => true,// ❌ should be hasTitleCaseModelLabel
'splitGlobalSearchTerms' => true, // ❌ should be shouldSplitGlobalSearchTerms
Copy the blocks under each trait in the README verbatim rather than deriving keys from the fluent method names.
null is a decision, not a reset. Passing ->navigationIcon(null) explicitly removes the icon even when the plugin ships a default and the resource declares its own static. That is deliberate and useful, but it means you cannot use null as "unset, go check the next tier." There is no sentinel for "no opinion."
A plugin trait without its resource counterpart is a no-op. The plugin trait stores and resolves; the resource trait reads. Add Plugin\HasNavigation but forget Resource\HasNavigation on UserResource and the fluent API accepts your calls and does nothing. Verify after wiring with a deliberate ->navigationLabel('ZZZ') and confirm it appears in the sidebar.
forResource() requires WithMultipleResourceSupport on the plugin class. The trait is not paired with a resource-side equivalent — nothing to add to UserResource. Omitting it means the chained proxy is unavailable and per-resource configuration is impossible for that plugin.
subNavigationPosition() takes the enum, not a string. SubNavigationPosition::End in the defaults array; a string will fail type expectations. Unlike the other navigation options, this one is not nullable — if you omit it everywhere, Filament's panel-level subNavigationPosition applies.
Badge closures run on every render of the navigation. fn () => User::whereNull('email_verified_at')->count() executes a query per page load. Cache it (Cache::remember('unverified', 60, ...)) or use the plugin's badge purely as a display hook, and mention this in your plugin's README, because consumers copy badge examples without thinking about query cost.
Pin the version. 26 stars, zero dependents, and a release dated 2026 — this is a young package with a small surface of real-world usage. Add a caret constraint on the minor ("^1.0"), and if you are publishing a plugin that depends on it, put it in require rather than require-dev and mention it in your own install docs. Trait-based APIs are also harder to shim than class-based ones, so check the changelog on upgrade.
The legacy flat defaults structure still resolves, so older examples you find in the wild (UserResource::class => [...] at the top level) are not broken — but prefer the nested 'resources' key in new code, since a resource class name is the only thing distinguishing the two shapes.
Extension points are intentionally limited. There is nothing to extend beyond the provided traits; if you need a setting the package does not model, add your own fluent method and static property on the resource and resolve it yourself. Do not fork the trait to add one option — the resolution ladder is the value, and a fork splits your bug surface from upstream's.
Debugging tip: to find which tier answered, temporarily override the property in question at tier 1 in your test panel provider. If the value changes, the plugin path works and the problem is your defaults key; if it does not change, the resource trait or getEssentialsPlugin() is missing. That single check separates the two failure modes in about thirty seconds.
How can I help you explore Laravel packages today?