Skip to content

User Resource Customization

Capell’s user resource is the host surface for editing users in the Filament admin panel. Packages can extend it without replacing the resource by using two separate extension points:

  • UserSchemaExtender adds form components, sidebar panels, and relation managers.
  • UserFormExtender mutates submitted form data and runs after create/save.

Use UserSchemaExtender for UI. Use UserFormExtender only when custom fields need persistence handling.

User resource bridges are package-owned integrations that add package-specific detail to the user editor. The admin package provides the host surface and global switches; each package owns its own setting, extender, relation managers, queries, and translations.

The effective load rule is:

Admin setting && package setting

Admin also exposes broader category switches for bridges that span package boundaries:

  • admin.enable_security_access_user_bridge
  • admin.enable_content_ownership_user_bridge
  • admin.enable_support_actions_user_bridge

Use these when a bridge contributes security/access panels, content ownership and publishing activity, or support actions. Keep package-owned settings in place when the package needs its own local disable switch.

Category settings are intended to be combined with package settings:

ShouldLoadUserResourceBridgeAction::run('enable_security_access_user_bridge', true)
&& ShouldLoadUserResourceBridgeAction::run(
'enable_login_audit_user_bridge',
resolve(LoginAuditSettings::class)->enable_user_resource_bridge,
);

For example, Login Audit panels are shown only when both of these are enabled:

  • admin.enable_login_audit_user_bridge
  • login_audit.enable_user_resource_bridge

The same pattern is used by:

  • admin.enable_publishing_studio_user_bridge and publishing_studio.enable_user_resource_bridge
  • admin.enable_agent_bridge_user_bridge and agent_bridge.enable_user_resource_bridge

Use Capell\Admin\Actions\Users\ShouldLoadUserResourceBridgeAction inside bridge extenders to enforce this rule.

The Login Audit bridge adds Security & Access to user records:

  • last login
  • failed login count
  • password last changed date where available
  • email verified date
  • MFA status where available
  • active sessions where available
  • audited session revocation and forced password reset actions where supported
  • login audit history relation manager

The Publishing Studio bridge adds Content Ownership & Publishing Activity to user records:

  • pages, posts, media, blocks, drafts, and other package-owned content created or updated by the user
  • recently published content
  • recently unpublished content
  • scheduled content
  • reverted content
  • publishing history relation managers scoped to the edited user

Prefer CMS-responsibility activity over generic audit trails so support staff can answer “what did this editor touch?” quickly.

Support action bridges add audited account-support controls to user records:

  • impersonate user, only when the auth model and permissions allow it
  • resend invite
  • verify email
  • deactivate or reactivate user
  • unlock account
  • force password reset

Every support action must be permission-gated and audited by the package that owns the action.

The Agent Bridge bridge adds AI / agent activity to user records:

  • tokens owned by the user
  • confirmations requested or approved
  • audit entries and capability usage
  • token status, last-used time, and expiry

Agent Bridge panels must never expose raw token values. Show metadata only.

Every user schema extender receives UserSchemaContextData:

use Capell\Admin\Data\Schemas\UserSchemaContextData;
$context->operation; // create or edit
$context->record; // edited model on edit, null on create
$context->roleNames; // role names resolved from the edited user
$context->schemaType; // role-derived schema type
$context->resourceName; // users

Use helpers when targeting roles or schema types:

if ($context->hasRole('editor') && $context->isSchemaType('editorial')) {
// Add editorial user controls.
}

Role-to-schema-type mapping is configured in capell-admin.php:

'user_resource' => [
'default_schema_type' => 'default',
'role_schema_types' => [
'super_admin' => 'administrator',
'editor' => 'editorial',
],
],

The first configured matching role wins. When nothing matches, Capell uses default_schema_type.

UserSchemaExtender::extendComponentsForHook() receives one of these hooks:

  • BeforeIdentity
  • AfterIdentity
  • BeforeCredentials
  • AfterCredentials
  • BeforeRoles
  • AfterRoles
  • BeforeProfile
  • AfterProfile
  • Footer

Add fields near the data they belong to. Avoid using the footer for package dashboards or record history; use sidebar panels or relation managers for that.

Extend AbstractUserSchemaExtender so new methods added later do not break your package.

<?php
declare(strict_types=1);
namespace Vendor\Package\Extenders;
use Capell\Admin\Actions\Users\ShouldLoadUserResourceBridgeAction;
use Capell\Admin\Data\Schemas\UserSchemaContextData;
use Capell\Admin\Enums\UserSchemaHookEnum;
use Capell\Admin\Support\Schemas\AbstractUserSchemaExtender;
use Filament\Forms\Components\TextInput;
use Filament\Schemas\Schema;
use Vendor\Package\Settings\PackageSettings;
final class PackageUserSchemaExtender extends AbstractUserSchemaExtender
{
public function supports(UserSchemaContextData $context): bool
{
return ShouldLoadUserResourceBridgeAction::run(
'enable_example_user_bridge',
resolve(PackageSettings::class)->enable_user_resource_bridge,
);
}
public function extendComponentsForHook(Schema $schema, UserSchemaHookEnum $hook, UserSchemaContextData $context): array
{
if ($hook !== UserSchemaHookEnum::AfterProfile) {
return [];
}
return [
TextInput::make('external_reference')
->label(__('vendor-package::form.external_reference'))
->maxLength(100),
];
}
}

Register the extender from your admin provider:

use Capell\Admin\Contracts\Extenders\UserSchemaExtender;
use Vendor\Package\Extenders\PackageUserSchemaExtender;
$this->app->bind(PackageUserSchemaExtender::class);
$this->app->tag([PackageUserSchemaExtender::class], UserSchemaExtender::TAG);

Schema extenders add UI only. If a field does not map directly to a column or relationship on the configured user model, pair it with UserFormExtender.

<?php
declare(strict_types=1);
namespace Vendor\Package\Extenders;
use Capell\Admin\Contracts\Extenders\UserFormExtender;
use Illuminate\Database\Eloquent\Model;
final class PackageUserFormExtender implements UserFormExtender
{
public function mutateDataBeforeCreate(array $data): array
{
return $this->moveExternalReference($data);
}
public function afterCreate(Model $record): void {}
public function mutateDataBeforeSave(Model $record, array $data): array
{
return $this->moveExternalReference($data);
}
public function afterSave(Model $record): void {}
private function moveExternalReference(array $data): array
{
$data['bio'] = $data['external_reference'] ?? $data['bio'] ?? null;
unset($data['external_reference']);
return $data;
}
}

Register it separately:

use Capell\Admin\Contracts\Extenders\UserFormExtender;
use Vendor\Package\Extenders\PackageUserFormExtender;
$this->app->bind(PackageUserFormExtender::class);
$this->app->tag([PackageUserFormExtender::class], UserFormExtender::TAG);

Use sidebar components for compact summaries. Keep row-level history in relation managers.

use Filament\Schemas\Components\Section;
use Filament\Schemas\Components\Text;
public function extendSidebarComponents(Schema $schema, UserSchemaContextData $context): array
{
if ($context->record === null) {
return [];
}
return [
Section::make(__('vendor-package::user.summary'))
->compact()
->schema([
Text::make(fn (): string => __('vendor-package::user.summary_line')),
]),
];
}

Do not put secrets, model IDs that users do not need, signed URLs, or raw package internals into sidebar panels.

Use relation managers for package-owned history and activity tables. EditUser resolves user bridge relation managers through Filament’s normal relation-manager filtering path, so canViewForRecord() still applies.

use Capell\Admin\Data\Schemas\UserSchemaContextData;
use Filament\Resources\RelationManagers\RelationManager;
use Illuminate\Database\Eloquent\Model;
public function extendRelationManagers(Model $record, array $relationManagers, UserSchemaContextData $context): array
{
return [
...$relationManagers,
PackageActivityRelationManager::class,
];
}
final class PackageActivityRelationManager extends RelationManager
{
public static function canViewForRecord(Model $ownerRecord, string $pageClass): bool
{
return auth()->user()?->can('viewPackageActivity', $ownerRecord) ?? false;
}
}

Every relation manager must scope its query to the edited user. Add tests proving unrelated users’ records are excluded.

Each bridge package should expose its own setting:

public bool $enable_user_resource_bridge = true;

Register the settings class and schema through SettingsSchemaRegistry, and add a guarded settings migration:

if (! $this->migrator->exists('example.enable_user_resource_bridge')) {
$this->migrator->add('example.enable_user_resource_bridge', true);
}

The package setting lets package owners disable only their bridge while leaving other user bridges available. The Admin setting lets site owners disable a whole bridge category globally.

The user resource includes an Admin Language field when the Capell migration has added users.preferred_admin_language_id.

Options are loaded from enabled Capell Language records. The selected language is applied only inside authenticated Capell/Filament admin requests. If the selected language is disabled, deleted, missing, or has an invalid locale value, Capell falls back to config('app.locale').

The preference is persisted by Capell’s user form save flow, so host user models do not need to include preferred_admin_language_id in $fillable.

For each user bridge, add focused tests that prove:

  • the bridge loads only when Admin and package settings are both enabled
  • sidebar components render on the edit-user form
  • relation managers are present only when allowed by canViewForRecord()
  • relation manager queries are scoped to the edited user
  • custom form fields persist through UserFormExtender when they are not native user columns
  • sensitive values, such as raw Agent Bridge tokens, are not rendered