diff --git a/docs/07-users/01-overview.md b/docs/07-users/01-overview.md
index 4c460d470a..60341c0578 100644
--- a/docs/07-users/01-overview.md
+++ b/docs/07-users/01-overview.md
@@ -95,7 +95,7 @@ The `getFilamentAvatarUrl()` method is used to retrieve the avatar of the curren
You can easily swap out [ui-avatars.com](https://ui-avatars.com) for a different service, by creating a new avatar provider.
-In this example, we create a new file at `app/Filament/AvatarProviders/BoringAvatarsProvider.php` for [boringavatars.com](https://boringavatars.com). The `get()` method accepts a user model instance and returns an avatar URL for that user:
+In this example, we create a new file at `app/Filament/AvatarProviders/BoringAvatarsProvider.php` for [boringavatars.com](https://boringavatars.com). The `get()` method accepts an Eloquent model or an `Authenticatable` user instance and returns an avatar URL for that record:
```php
+Custom avatar providers must accept non-Eloquent users
+
+The `AvatarProvider::get()` method now accepts `Model | Authenticatable` instead of only `Model`, so avatar providers can receive users that do not extend an Eloquent model.
+
+If you have a custom avatar provider, update its `get()` parameter type:
+
+```php
+use Illuminate\Contracts\Auth\Authenticatable;
+use Illuminate\Database\Eloquent\Model;
+
+public function get(Model | Authenticatable $record): string
+{
+ // Return the avatar URL for the record.
+}
+```
+
+The automated upgrade script updates this parameter type when `get()` is declared directly on a class implementing `AvatarProvider` or an interface extending it. If your provider uses a `get()` method declared in a trait or inherited from a parent class that does not implement `AvatarProvider`, you must update that declaration manually.
+
+You should review your provider's implementation for Eloquent-specific method calls and handle non-Eloquent users if your application uses them. Providers that already accept both types, `object`, `mixed`, or an untyped parameter do not need a signature change.
+
+See the [custom avatar provider documentation](../users/overview#using-a-different-avatar-provider) for more details.
+
+
The modal content is now wrapped in a `fi-modal-window-scroll` element when using a sticky header or footer
diff --git a/packages/panels/src/AvatarProviders/Contracts/AvatarProvider.php b/packages/panels/src/AvatarProviders/Contracts/AvatarProvider.php
index 3ebfc7ac67..54d66bd90f 100644
--- a/packages/panels/src/AvatarProviders/Contracts/AvatarProvider.php
+++ b/packages/panels/src/AvatarProviders/Contracts/AvatarProvider.php
@@ -2,9 +2,10 @@
namespace Filament\AvatarProviders\Contracts;
+use Illuminate\Contracts\Auth\Authenticatable;
use Illuminate\Database\Eloquent\Model;
interface AvatarProvider
{
- public function get(Model $record): string;
+ public function get(Model | Authenticatable $record): string;
}
diff --git a/packages/upgrade/src/Rector/WidenAvatarProviderParameterTypeRector.php b/packages/upgrade/src/Rector/WidenAvatarProviderParameterTypeRector.php
new file mode 100644
index 0000000000..e7f5213a6e
--- /dev/null
+++ b/packages/upgrade/src/Rector/WidenAvatarProviderParameterTypeRector.php
@@ -0,0 +1,82 @@
+isObjectType($node, new ObjectType('Filament\\AvatarProviders\\Contracts\\AvatarProvider'))) {
+ return null;
+ }
+
+ $parameter = $node->getMethod('get')?->params[0] ?? null;
+
+ if (! $parameter?->type) {
+ return null;
+ }
+
+ $parameterType = $this->getType($parameter->type);
+ $authenticatableType = new ObjectType('Illuminate\\Contracts\\Auth\\Authenticatable');
+
+ if ($parameterType->isSuperTypeOf($authenticatableType)->yes()) {
+ return null;
+ }
+
+ $parameter->type = $this->staticTypeMapper->mapPHPStanTypeToPhpParserNode(
+ TypeCombinator::union($parameterType, $authenticatableType),
+ TypeKind::PARAM,
+ );
+
+ return $node;
+ }
+}
diff --git a/packages/upgrade/src/rector.php b/packages/upgrade/src/rector.php
index e698ae881a..120dda1752 100644
--- a/packages/upgrade/src/rector.php
+++ b/packages/upgrade/src/rector.php
@@ -1,6 +1,7 @@
ruleWithConfiguration(RemoveTraitUseRector::class, [
'Filament\\Schemas\\Concerns\\RestrictsFileUploadsToSchemaComponents',
]);
+
+ $rectorConfig->rule(WidenAvatarProviderParameterTypeRector::class);
};
diff --git a/tests/src/Panels/Configuration/AvatarProviderTest.php b/tests/src/Panels/Configuration/AvatarProviderTest.php
index 3ec155071f..80a552d79f 100644
--- a/tests/src/Panels/Configuration/AvatarProviderTest.php
+++ b/tests/src/Panels/Configuration/AvatarProviderTest.php
@@ -1,8 +1,13 @@
toStartWith('https://ui-avatars.com/api/');
});
+it('generates default avatars for `Model` and non-Eloquent `Authenticatable` records', function (bool $isEloquent): void {
+ $record = $isEloquent
+ ? new class extends Model implements HasAvatar, HasName
+ {
+ public function getFilamentAvatarUrl(): ?string
+ {
+ return null;
+ }
+
+ public function getFilamentName(): string
+ {
+ return 'Taylor Morgan';
+ }
+ }
+ : new class([]) extends GenericUser implements HasAvatar, HasName
+ {
+ public function getFilamentAvatarUrl(): ?string
+ {
+ return null;
+ }
+
+ public function getFilamentName(): string
+ {
+ return 'Jordan Ellis';
+ }
+ };
+
+ $url = Filament::getUserAvatarUrl($record);
+
+ expect($url)->toStartWith('https://ui-avatars.com/api/');
+ expect($url)->toContain($isEloquent ? 'name=T+M' : 'name=J+E');
+})->with([
+ 'Eloquent model' => [true],
+ 'non-Eloquent user' => [false],
+]);
+
it('includes encoded name in the URL', function (): void {
$provider = new UiAvatarsProvider;
$user = User::factory()->create(['name' => 'Jane Smith']);
diff --git a/tests/src/Upgrade/Fixtures/avatar_providers.php.inc b/tests/src/Upgrade/Fixtures/avatar_providers.php.inc
new file mode 100644
index 0000000000..96258c9872
--- /dev/null
+++ b/tests/src/Upgrade/Fixtures/avatar_providers.php.inc
@@ -0,0 +1,139 @@
+doTestFile(__DIR__ . '/Fixtures/avatar_providers.php.inc');
+ }
+
+ public function provideConfigFilePath(): string
+ {
+ return dirname(__DIR__, 3) . '/packages/upgrade/src/rector.php';
+ }
+}