Customizing and extending
Add your own resources next to Odden's, replace an Odden resource with a subclass, override the plugin's Blade views, and make sure the custom pages are styled.
OddenPlugin has fluent options to turn modules off, leave out individual resources and pages, and swap in your own subclasses (see Plugin options). You can also add your own Filament classes next to the plugin.
Plugin options
use App\Filament\Resources\ContactResource as AppContactResource;
use Odden\Filament\OddenPlugin;
use Odden\Filament\Pages\DataQuality;
use Odden\Filament\Resources\ContactResource;
use Odden\Filament\Resources\LeadRoutingRuleResource;
return $panel
// ...
->plugins([
OddenPlugin::make()
->disableModules('marketing') // a paid add-on: leaves out its screens
->except(LeadRoutingRuleResource::class, DataQuality::class)
->replace(ContactResource::class, AppContactResource::class),
]);| Method | Effect |
|---|---|
disableModules(string ...$modules) |
Leaves out every resource and page of sales, or of the paid service and marketing add-ons. Any other name throws InvalidArgumentException. Core's resources and pages are always registered. |
except(string ...$classes) |
Leaves out specific resources or pages. |
replace(string $original, string $replacement) |
Registers your class in place of an Odden resource or page. |
A module whose package isn't installed is skipped either way. The options apply when the plugin registers, so they work for pages as well as resources. Leaving a resource out removes its routes and navigation, and other Odden pages may still link to it (for example the deal view links to quotes), so disable a whole module rather than a resource the rest of the module depends on.
A replacement should be a subclass that keeps the resource's slug, as described below.
Adding your own resources and pages
The plugin only adds to the panel, so your own resources, pages and widgets work alongside it as usual:
use Filament\Pages\Dashboard;
use Odden\Filament\OddenPlugin;
return $panel
// ...
->plugins([
OddenPlugin::make(),
])
->discoverResources(in: app_path('Filament/Resources'), for: 'App\Filament\Resources')
->discoverPages(in: app_path('Filament/Pages'), for: 'App\Filament\Pages')
->pages([
Dashboard::class,
]);Your resources can use Odden models directly, for example a resource for Odden\Core\Models\Activity. Avoid slugs the plugin already uses (contacts, companies, deals and so on, listed in URLs and route names). Filament derives a resource's slug from its class name, so App\Filament\Resources\ContactResource would also get contacts and clash with the plugin's routes.
To put your items into Odden's navigation groups, use the same group labels: CRM, Sales, Service, Marketing, Executive or Settings.
Replacing a resource
To change an Odden resource, subclass it and register the subclass with OddenPlugin::replace(). If you want full control instead, skip the plugin on that panel and register the resources and pages you want yourself.
Start with the resource subclass. Filament derives the slug from the class name, so naming it ContactResource keeps the contacts URLs and route names the other Odden resources link to. With a different class name, set protected static ?string $slug = 'contacts';.
<?php
namespace App\Filament\Resources;
use App\Filament\Resources\ContactResource\Pages\CreateContact;
use App\Filament\Resources\ContactResource\Pages\EditContact;
use App\Filament\Resources\ContactResource\Pages\ListContacts;
use App\Filament\Resources\ContactResource\Pages\ViewContact;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table;
use Odden\Filament\Resources\ContactResource as OddenContactResource;
use Illuminate\Database\Eloquent\Builder;
use UnitEnum;
class ContactResource extends OddenContactResource
{
protected static UnitEnum|string|null $navigationGroup = 'People';
public static function table(Table $table): Table
{
return parent::table($table)
->pushColumns([
TextColumn::make('owner.name')->label('Owner'),
]);
}
public static function getEloquentQuery(): Builder
{
return parent::getEloquentQuery()->where('owner_id', auth()->id());
}
public static function getPages(): array
{
return [
'index' => ListContacts::route('/'),
'create' => CreateContact::route('/create'),
'view' => ViewContact::route('/{record}'),
'edit' => EditContact::route('/{record}/edit'),
];
}
}Override getPages() and subclass the pages as well. Each Odden page class sets protected static string $resource to the Odden resource, and Filament pages call their own resource's table(), form() and getEloquentQuery(). If your resource only overrides navigation properties ($navigationGroup, $navigationLabel, $navigationSort, $navigationIcon), the original pages are enough. But a table(), form() or getEloquentQuery() override has no effect unless the pages point at your class:
<?php
namespace App\Filament\Resources\ContactResource\Pages;
use App\Filament\Resources\ContactResource;
use Odden\Filament\Resources\ContactResource\Pages\ListContacts as OddenListContacts;
class ListContacts extends OddenListContacts
{
protected static string $resource = ContactResource::class;
}Create CreateContact, EditContact and ViewContact the same way, each extending the matching class in Odden\Filament\Resources\ContactResource\Pages. With all four pages pointing at your resource, the getEloquentQuery() scope above applies to the table and to the view and edit URLs. A contact owned by someone else then returns a 404.
Then register the subclass with OddenPlugin::make()->replace(ContactResource::class, App\Filament\Resources\ContactResource::class). If you'd rather not use the plugin, register everything on the panel yourself. This example rebuilds the core part of the plugin:
use App\Filament\Resources\ContactResource;
use Odden\Filament\Pages\DataQuality;
use Odden\Filament\Pages\ExecutiveOverview;
use Odden\Filament\Resources\CompanyResource;
use Odden\Filament\Resources\CrmListResource;
use Odden\Filament\Resources\PropertyDefinitionResource;
return $panel
// ...
->resources([
ContactResource::class,
CompanyResource::class,
CrmListResource::class,
PropertyDefinitionResource::class,
])
->pages([
ExecutiveOverview::class,
DataQuality::class,
]);Add the sales classes the same way. How modules are detected lists what the plugin registers for each module. When you register them yourself, the class_exists() checks are up to you. A paid add-on's classes are in its own namespace; see Add-on modules.
Swapping a built-in behaviour
The actions the panel uses are behind contracts, so an application (or an add-on package) can replace what they do without touching the panel. Bind the contract in a service provider; the panel asks the container for it. The built-in implementation stays the default until you do.
| Contract | Used by | Built-in implementation |
|---|---|---|
Odden\Core\Contracts\SummarizesTimeline |
AI Briefing on contacts and companies | SummarizeTimelineAction: rule-based, no external call. |
use Odden\Core\Contracts\SummarizesTimeline;
// In a service provider's register() method.
$this->app->bind(SummarizesTimeline::class, App\Support\MyTimelineSummarizer::class);A replacement has to return the shape the contract documents; the panel reads those keys. Some of the contracts leave room:
-
DraftsTicketReplyonly fills the reply box. The agent reads it, edits it and posts it; nothing is sent from the contract. -
PublishesAdAudiencemay return amessage, which Sync Now shows instead of its default wording (for example "Uploaded 1,204 members to Meta.").hashed_emailsandhashed_domainsare optional, since an implementation that uploads has no reason to hand every hash back. -
Nothing requires a replacement to extend the built-in class. Implement the interface.
The paid Marketing and Service add-ons add contracts of their own (subject lines, ticket reply drafts, ad audience sync); each is documented with the add-on.
The default is registered with bindIf, so your binding wins whatever order the providers load in.
Overriding views
The plugin's custom pages and modals render Blade views from the odden-filament namespace:
| View | Used by |
|---|---|
odden-filament::pages.executive-overview |
ExecutiveOverview |
odden-filament::pages.data-quality |
DataQuality |
odden-filament::pages.sales-cockpit |
SalesCockpit |
odden-filament::pages.deal-kanban |
DealResource board page |
odden-filament::components.ai-briefing-modal |
AI Briefing action on contacts and companies |
The package doesn't register any publishable files, so vendor:publish has nothing to copy. Laravel still checks your app's resources/views/vendor/odden-filament directory first, so you can override a view by copying it there under the same relative path:
mkdir -p resources/views/vendor/odden-filament/pages
cp vendor/getodden/crm-filament/resources/views/pages/sales-cockpit.blade.php \
resources/views/vendor/odden-filament/pages/sales-cockpit.blade.phpLaravel only picks up the override directory if it exists when the application boots. The views call public properties and methods on the page classes (for example $this->guidedActions or wire:click="advanceEnrollment(...)"), so check your copy whenever you upgrade the package.
The paid add-ons ship their own views under their own namespaces (odden-marketing-filament, odden-service-filament), documented with each add-on. The deal health score modal is odden-filament::deals.health-score-modal, so override it in resources/views/vendor/odden-filament/deals.
Styling the custom pages
Most of the custom pages (the Sales Cockpit, Executive Overview, Data Quality and the boards) carry most of their styling in a <style> block inside the view, with only a few Tailwind utility classes.
Some pages added by the paid add-ons are styled with Tailwind utility classes; see each add-on's documentation if you build a custom Filament theme.
php artisan make:filament-themeThen add this line to theme.css, next to the @source lines Filament generated:
@source '../../../../vendor/getodden/crm-filament/resources/views/**/*';The path is relative to theme.css. Rebuild your assets afterwards.