F IdentityFormatter contract — apps override how the User column renders

websockets:watch -v now delegates User-column rendering to a bound
IdentityFormatter. The package ships DefaultIdentityFormatter which
produces #<id> - <name> | <username> - <email> for typical Eloquent users
(any field absent = that segment dropped). Apps with non-User auth
subjects (Company, ApiClient, multi-tenant blobs) can implement the
contract and either bind it in their service provider or name it in
config/websockets.php as 'identity_formatter'. Resolution order is:
explicit container binding > config class > package default.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Fabian @ Blax Software 2026-04-27 13:50:22 +02:00
parent e45d8dff20
commit a46243a706
5 changed files with 169 additions and 12 deletions

View File

@ -55,6 +55,29 @@ return [
*/ */
'auth_resolver' => null, 'auth_resolver' => null,
/*
|--------------------------------------------------------------------------
| Identity Formatter
|--------------------------------------------------------------------------
|
| Class used by admin tooling (`php artisan websockets:watch -v`) to render
| the "User" column for each connection. The default formatter handles the
| typical Eloquent User shape (id / name / username / email) and produces
| strings like `#42 - Alice | alice42 - alice@example.com`, dropping any
| suffix whose source field is missing.
|
| Apps with non-User auth subjects (multi-tenant company accounts, api
| clients, etc.) can implement
| `BlaxSoftware\LaravelWebSockets\Contracts\IdentityFormatter` and either
| name the class here, or bind it directly:
|
| $this->app->bind(IdentityFormatter::class, MyFormatter::class);
|
| Leave null to use the package default.
|
*/
'identity_formatter' => null,
/* /*
|-------------------------------------------------------------------------- |--------------------------------------------------------------------------
| Max Concurrent Children (Fork Limit) | Max Concurrent Children (Fork Limit)

View File

@ -4,6 +4,7 @@ declare(strict_types=1);
namespace BlaxSoftware\LaravelWebSockets\Console\Commands; namespace BlaxSoftware\LaravelWebSockets\Console\Commands;
use BlaxSoftware\LaravelWebSockets\Contracts\IdentityFormatter;
use BlaxSoftware\LaravelWebSockets\Services\WebsocketService; use BlaxSoftware\LaravelWebSockets\Services\WebsocketService;
use Illuminate\Console\Command; use Illuminate\Console\Command;
@ -145,21 +146,20 @@ class WatchStats extends Command
return '<fg=gray>Guest</>'; return '<fg=gray>Guest</>';
} }
$user = WebsocketService::getAuth($socketId); $subject = WebsocketService::getAuth($socketId);
if (! $user) { if (! $subject) {
// Authed but the per-socket user blob expired / was evicted. // Authed-but-expired: the socket is in ws_socket_authed_users
// but the per-socket user blob has fallen out of cache. Show the
// bare id from authedUsers so the connection isn't mislabeled
// as a Guest.
return '<fg=yellow>#' . $authedUsers[$socketId] . '</>'; return '<fg=yellow>#' . $authedUsers[$socketId] . '</>';
} }
// Try common identity fields in order of usefulness. Fall back to the // Delegate formatting to the bound IdentityFormatter so apps with
// user id so we always have something to display. // non-User subjects (Company, ApiClient, etc.) can render their own
$label = $user->name // shape without forking the package.
?? $user->display_name $formatter = app(IdentityFormatter::class);
?? $user->username return $formatter->format($subject, $socketId);
?? $user->email
?? ('#' . ($user->id ?? '?'));
return (string) $label;
} }
private function formatDuration(string $socketId, int $now): string private function formatDuration(string $socketId, int $now): string

View File

@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
namespace BlaxSoftware\LaravelWebSockets\Contracts;
/**
* Renders the "who" associated with a websocket connection for admin tooling
* (`websockets:watch -v`, server-info dumps, debug logs).
*
* The package has no opinion on what kind of subject is authenticated against
* a socket most apps store an Eloquent User, but a multi-tenant app might
* authenticate a Company, an api-only app might use an ApiClient, etc.
* `WebsocketService::setUserAuthed()` accepts whatever object the app passes,
* so the corresponding formatter is the app's responsibility too.
*
* The package ships a `DefaultIdentityFormatter` that handles the common
* Eloquent-User shape (id / name / username / email). Apps that need
* different fields, multiple subject types, or custom formatting can bind
* their own implementation:
*
* // In an app service provider:
* $this->app->bind(
* \BlaxSoftware\LaravelWebSockets\Contracts\IdentityFormatter::class,
* \App\Websockets\MyIdentityFormatter::class,
* );
*
* Or via config (`config/websockets.php`):
*
* 'identity_formatter' => \App\Websockets\MyIdentityFormatter::class,
*/
interface IdentityFormatter
{
/**
* Return a human-readable label for the given subject.
*
* @param mixed $subject Whatever was cached via setUserAuthed() an
* Eloquent model, a stdClass, null for guests,
* or an arbitrary value the app stored.
* @param string $socketId The socket id this subject is attached to.
* Useful if the formatter wants to pull
* additional context from elsewhere.
*/
public function format(mixed $subject, string $socketId): string;
}

View File

@ -0,0 +1,64 @@
<?php
declare(strict_types=1);
namespace BlaxSoftware\LaravelWebSockets\Identity;
use BlaxSoftware\LaravelWebSockets\Contracts\IdentityFormatter;
/**
* Default identity formatter handles the common case of an Eloquent User
* (or any object exposing id / name / username / email properties).
*
* Output shape: `#<id> - <name> | <username> - <email>` with each suffix
* dropped if the corresponding field is missing or null. Returns "Guest"
* for null subjects and for subjects that have no usable id at all.
*
* Apps with a different subject shape (Company, ApiClient, custom auth blob)
* should implement `IdentityFormatter` and bind it in their service provider
* see the interface docblock for an example.
*/
class DefaultIdentityFormatter implements IdentityFormatter
{
public function format(mixed $subject, string $socketId): string
{
if (! $subject) {
return 'Guest';
}
$id = $this->read($subject, 'id');
if ($id === null || $id === '') {
return 'Guest';
}
$out = '#' . $id;
if ($name = $this->read($subject, 'name')) {
$out .= ' - ' . $name;
}
if ($username = $this->read($subject, 'username')) {
$out .= ' | ' . $username;
}
if ($email = $this->read($subject, 'email')) {
$out .= ' - ' . $email;
}
return $out;
}
/**
* Best-effort property read across Eloquent models, stdClass, and arrays.
*/
private function read(mixed $subject, string $key): mixed
{
if (is_array($subject)) {
return $subject[$key] ?? null;
}
if (is_object($subject)) {
// Eloquent and stdClass both support property access; missing
// attributes on Eloquent return null instead of throwing.
return $subject->{$key} ?? null;
}
return null;
}
}

View File

@ -31,6 +31,7 @@ class WebSocketsServiceProvider extends ServiceProvider
$this->registerWebSocketHandler(); $this->registerWebSocketHandler();
$this->registerRouter(); $this->registerRouter();
$this->registerManagers(); $this->registerManagers();
$this->registerIdentityFormatter();
$this->registerBroadcastAuthRoute(); $this->registerBroadcastAuthRoute();
$this->registerCommands(); $this->registerCommands();
} }
@ -81,6 +82,30 @@ class WebSocketsServiceProvider extends ServiceProvider
}); });
} }
/**
* Bind the identity formatter used by admin tooling (`websockets:watch -v`).
*
* Resolution order:
* 1. Whatever is already bound to `Contracts\IdentityFormatter` apps
* that called `$this->app->bind()` in their own provider win, because
* this register runs in `boot()` after their `register()`.
* 2. The class named in `config('websockets.identity_formatter')`.
* 3. The package default (`DefaultIdentityFormatter`).
*/
protected function registerIdentityFormatter()
{
if ($this->app->bound(Contracts\IdentityFormatter::class)) {
return;
}
$configured = config('websockets.identity_formatter');
$class = is_string($configured) && $configured !== ''
? $configured
: Identity\DefaultIdentityFormatter::class;
$this->app->singleton(Contracts\IdentityFormatter::class, $class);
}
protected function registerBroadcastAuthRoute() protected function registerBroadcastAuthRoute()
{ {
if (! Route::has('broadcasting/auth')) { if (! Route::has('broadcasting/auth')) {