anomaly/users-module
Anomaly Users Module adds complete user management to Laravel and Streams Platform applications: authentication, registration, profiles, roles, permissions, password resets, and an admin-ready CRUD interface for managing accounts and access control.
Install
composer require anomaly/users-module
The module only makes sense inside the Streams Platform ecosystem (PyroCMS 3.x or a hand-rolled Streams app), so the first real prerequisite is that anomaly/streams-platform is already installed and bootstrapped. If you are on a stock PyroCMS distribution, the module normally ships already — check composer show anomaly/users-module before adding anything.
Boot it
Streams addons do not use Laravel package auto-discovery. The service provider is declared in the addon manifest (src/UsersModuleServiceProvider.php registered via module.json / resources/config/module.php). Then:
php artisan addon:install anomaly.module.users
# which internally runs
php artisan users:install # creates users/roles/groups/permissions streams + seeds admin
Run php artisan migrate afterwards only if you have your own migrations referencing the users tables — the addon install handles the module's own schema.
Where to look first
src/User/ — the UserModel, UserRepository, UserPresenter, UserCriteria, and the HasRoles/HasPermissions traits. This is 80% of what you will touch.src/Auth/ — controllers for login, logout, register, activate, forgot/reset password.src/Role/, src/Group/, src/Permission/ — same repository/model/presenter shape.src/Command/ — the artisan commands (users:install, users:seed, users:cleanup).resources/views/ — the actual forms; override these by copying into your theme, not by editing vendor.resources/config/ — feature toggles (registration, activation requirements, default role).First use case
Guard a route and check a role in a controller or Blade view:
use Anomaly\UsersModule\User\Contract\UserRepositoryInterface;
$user = app(UserRepositoryInterface::class)->findByEmail($email);
if ($user && $user->hasRole('admin')) { /* ... */ }
@if(auth()->check() && auth()->user()->hasPermission('anomaly.module.pages::pages.manage'))
<a href="{{ route('admin.pages.index') }}">Pages</a>
@endif
Resolve through contracts, not models. Every entity has an interface (UserInterface, RoleInterface, PermissionInterface) bound to a repository. Injecting UserRepositoryInterface gives you findByEmail, findByUsername, findBySlug, and criteria chaining (->active()->role('admin')->get()), and it keeps you insulated from schema changes across module updates.
Middleware aliases. The module registers route middleware under the Streams :: convention:
Route::group(['middleware' => 'anomaly.module.users::authenticate'], function () { ... });
Route::get('/reports', 'ReportController@index')->middleware('anomaly.module.users::can:anomaly.module.reports::reports.view');
Use anomaly.module.users::guest on login/register routes so logged-in users are bounced to the redirect target rather than seeing the form again.
Register your own permissions. Add them to the module service provider's permissions array (or the module class), using the canonical slug format vendor.module::section.action. The admin UI permission matrix reads these directly, so anything you register becomes assignable to roles without extra UI work. Use the Streams translator for labels: 'anomaly.module.reports::permission.view'.
Hook the lifecycle with events. The module fires domain events you should listen to instead of overriding controllers:
UserWasRegistered / UserHasRegisteredUserWasActivatedUserHasLoggedIn / UserHasLoggedOutUserPasswordWasResetTypical pattern: listen for UserWasRegistered to push the user into a CRM, assign an onboarding role, or send a Slack ping. Register listeners in your own module's service provider — do not edit the users module's event map.
Extend the user profile via Streams fields. Users is a Streams entity; add profile columns by adding fields/assignments in a migration, not by editing the module's migrations:
$stream->addField('company', ['type' => 'anomaly.field_type.text']);
The user form and presenter pick these up automatically, and $user->company works through the Streams attribute accessor.
Custom auth flows. If you need a bespoke login endpoint (API token, SSO), reuse the module's AuthService/LoginFormHandler rather than calling Auth::attempt yourself — it routes through activation checks, throttling, and the remember-me/redirect logic. Bind your controller to the existing form handler and override only the response.
Scheduled housekeeping. php artisan users:cleanup clears expired activation codes and stale reset tokens. Schedule it hourly in app/Console/Kernel.php — the module does not schedule it for you.
No Laravel auto-discovery. Adding the composer package alone does nothing. If routes 404 and auth()->user() returns null, the addon provider was never booted. Confirm with php artisan addon:list and php artisan route:list | grep users.
Config keys use the :: namespace. config('anomaly.module.users::config.registration.enabled'), not config('users.registration'). Reading the wrong key silently returns null and defaults look like "feature is off." The module's resources/config/ is the source of truth — read it before assuming a setting doesn't exist.
Activation is a footgun in local/dev. New users may be unable to log in until activated (email or admin approval), and mail is often unconfigured locally. Either set the activation toggle in the users config to disabled for local environments, or run users:cleanup plus manually activate via the admin. When a login "fails silently," check $user->isActivated() before debugging credentials.
The users tables are Streams-managed. Don't hand-write migrations that ALTER TABLE users. Add Streams fields/assignments; otherwise the Streams compile step will fight you and the admin form won't know about your column. If you do need raw SQL, mirror it in a Streams field definition.
Version lock-in. This module is built against the Laravel 5.4–8 era of Streams Platform. Dropping it into a Laravel 10/11 app is not supported — the contracts, service provider signatures, and Auth internals have moved. Verify composer why anomaly/streams-platform before upgrading Laravel.
Presenter vs. model. In Blade you usually want $user->presenter() for display name, avatar, and role badges; the raw model returns IDs and Streams entry objects that render poorly. Calling $user->display_name on the model works but bypasses formatting.
Permission slugs are exact-match strings. A typo in a slug means the check returns false with no error — no exception, no log line. When a permission "doesn't work," dump $user->getPermissions() (or the role's permission array) and diff against the string in your middleware. Slugs are case-sensitive.
Override views by publishing, not patching. Copy resources/views/ into your theme's override path. Editing vendor/anomaly/users-module/resources/views/ survives exactly one composer update.
Repository criteria are lazy. UserRepositoryInterface returns a query-like object; forgetting ->get() or ->first() returns a criteria instance, not a user, and you'll get confusing "call to undefined method" errors downstream. When in doubt, var_dump(get_class($result)).
Extension points if you outgrow it: bind your own implementation of UserRepositoryInterface in a service provider, or extend UserModel and rebind UserInterface. Both are supported; subclassing the controller is not — controller actions are wired to routes via the addon's route config.
How can I help you explore Laravel packages today?