studio/laravel-totem
Laravel Totem provides a Horizon-style dashboard to manage Laravel Scheduler jobs. Create, enable/disable, and edit scheduled Artisan commands without changing code. Includes migrations/assets, auth customization, and supports Laravel 11/12 on PHP 8.2+.
Date: 2026-02-24 Goal: Bring every outdated pattern up to Laravel 11+ and PHP 8.2+ conventions without rewriting the architecture, targeting Taylor Otwell-level code quality. Approach: Surgical modernization — targeted, auditable changes with full test coverage.
Problem: TotemRouteServiceProvider extends Illuminate\Foundation\Support\Providers\RouteServiceProvider, which was retired in Laravel 11. The $namespace property and ->namespace() chain on Route:: are also removed.
Solution:
src/Providers/TotemRouteServiceProvider.phpTotemServiceProvider::boot() using plain Route::group():Route::prefix(config('totem.web.route_prefix', 'totem'))
->middleware(config('totem.web.middleware'))
->group(__DIR__.'/../../routes/web.php');
routes/web.php from Laravel 5 string syntax to tuple syntax:// Before
Route::get('/', 'DashboardController@index')->name('totem.dashboard');
// After
Route::get('/', [DashboardController::class, 'index'])->name('totem.dashboard');
mapApiRoutes() and the empty routes/api.php file entirely.$namespace property from route service provider entirely.ConsoleServiceProvider registration as a separate class; merge its boot() logic into TotemServiceProvider::boot().Problem: TOTEM_PATH, TOTEM_TABLE_PREFIX, TOTEM_DATABASE_CONNECTION are PHP global constants defined at runtime in register(). This pollutes the global namespace and makes the package fragile.
Solution: Remove all three define() calls. Read directly from config:
// TotemModel — before
protected $connection = TOTEM_DATABASE_CONNECTION;
// TotemModel — after
public function getConnectionName(): ?string
{
return config('totem.database_connection', config('database.default'));
}
public function getTable(): string
{
$prefix = config('totem.table_prefix', '');
$table = parent::getTable();
return str_starts_with($table, $prefix) ? $table : $prefix.$table;
}
Replace all remaining TOTEM_TABLE_PREFIX usages in Result.php and EloquentTaskRepository.php with config('totem.table_prefix', '').
Replace TOTEM_PATH in defineAssetPublishing() with __DIR__.'/../../'.
CronExpression::factory() → new CronExpression()Problem: The static factory() method was removed from dragonmantank/cron-expression v3.
Solution: Replace both usages:
// Before
CronExpression::factory($expression)->getNextRunDate()
// After
(new CronExpression($expression))->getNextRunDate()
Affected files: src/Task.php (line 75), src/Http/Controllers/UpcomingTasksController.php (line 51).
Validator::extend() → Custom Rule ObjectsProblem: Validator::extend() is deprecated since Laravel 9 and will be removed.
Solution: Create two Rule classes:
src/Rules/CronExpression.php
class CronExpression implements ValidationRule
{
public function validate(string $attribute, mixed $value, Closure $fail): void
{
if (! \Cron\CronExpression::isValidExpression($value)) {
$fail('This is not a valid cron expression.');
}
}
}
src/Rules/JsonFile.php
class JsonFile implements ValidationRule
{
public function validate(string $attribute, mixed $value, Closure $fail): void
{
if ($value->getClientOriginalExtension() !== 'json') {
$fail('The file must be a JSON file.');
}
}
}
Update TaskRequest and ImportRequest to use these rules.
Remove both Validator::extend() calls from TotemServiceProvider::boot().
Problem: NexmoMessage and the nexmo notification channel were removed from Laravel. Nexmo is now Vonage.
Solution:
laravel/vonage-notification-channel to composer.json suggest (keep as optional)TaskCompleted.php: NexmoMessage → VonageMessage, toNexmo() → toVonage(), 'nexmo' channel string → 'vonage'TaskRequest phone validation messagerequest() from HasFrequencies Model TraitProblem: HasFrequencies::processData() calls request() inside a model — a separation of concerns violation. Models should not know about HTTP.
Solution: Move processData() logic into EloquentTaskRepository::import() where the HTTP context already exists. The model trait's afterSave() should receive pre-processed data, not reach into the request.
The afterSave() model hook can remain but should not call processData() directly — instead it should work off $this->frequencies data that was set by the repository before saving.
Problem: BroadcastingEvent uses an old anonymous broadcasting pattern.
Solution: Use typed channels and broadcastAs() method per Laravel 11 conventions. Update TotemEventServiceProvider to use FQCN strings → class constants for the $listen array.
Problem: laravel-elixir and gulp@3 are incompatible with Node 20+. The build is completely broken without the workaround webpack.config.js added recently.
Solution:
webpack.config.js and laravel-elixir dependenciespackage.json with modern tooling:{
"private": true,
"scripts": {
"dev": "vite build --watch",
"build": "vite build"
},
"devDependencies": {
"[@vitejs](https://github.com/vitejs)/plugin-vue": "^5.0",
"axios": "^1.0",
"dayjs": "^1.11",
"uikit": "^3.0",
"vite": "^5.0",
"vue": "^3.4"
}
}
vite.config.js outputting public/js/app.js (same published path, no Blade changes needed)Problem: Vue 2 is EOL. Multiple breaking patterns in use: new Vue(), Vue.mixin(), inline-template.
Solution:
Replace bootstrap:
// Before
new Vue({ el: '#root', components: {...} })
// After
const app = createApp({})
// register components
app.mount('#root')
Replace global mixin with composable:
// composables/useFormatDate.js
export function useFormatDate() {
const formatDate = (unixTime) => dayjs(unixTime * 1000)
const readableTimestamp = (timestamp) => formatDate(timestamp).format('HH:mm:ss')
return { formatDate, readableTimestamp }
}
Components requiring significant change:
TaskType.vue — uses inline-template (removed in Vue 3). Must be rewritten as a proper SFC with its own <template> block. The parent Blade template passes data as props instead.<script setup>)moment dependency (~290KB parsed)dayjs (~7KB parsed)Affected: UpcomingCalendar.vue, app.js composable.
public/js/app.jsRun npm run build to produce the updated bundle. Commit the updated public/js/app.js.
Add missing return types:
Task::autoCleanup(): voidTasksController::destroy(): RedirectResponseTaskRequest::authorize(): boolTaskRequest::rules(): arrayTaskRequest::messages(): arrayTaskRequest::validationData(): arrayAuthenticate::handle(): mixedRemove PHPDoc [@param](https://github.com/param)/[@return](https://github.com/return) blocks that are made redundant by PHP 8.x type signatures (Taylor's style: type hints in signatures are the documentation).
Replace \Exception with \Throwable in catch blocks where appropriate.
helpers.php — Remove Font Awesome DependencycolumnSort() outputs <span class="fa fa-caret-up"> which hardcodes Font Awesome (not shipped with the package). Replace with UIKit icon syntax that's already available:
$icon = '<span uk-icon="icon: triangle-up"></span>';
src/User.php — Move to TestsThe User model in src/ exists solely for tests and can conflict with host app models. Move to tests/ and update TestCase accordingly. Do not auto-load it from the production src/ path.
routes/api.php (empty file)mapApiRoutes() from route providertotem.api config key (unused)All existing 46 assertions must continue to pass. New tests to add:
Rules/CronExpressionTest.php — valid and invalid expressionsRules/JsonFileTest.php — valid json file, non-json fileFeature/VonageNotificationTest.php — verify toVonage() returns VonageMessage with correct contentFeature/TotemModelConnectionTest.php — verify getConnectionName() reads from config, not constantFeature/TotemModelTablePrefixTest.php — verify table prefix applied from config| Area | Files |
|---|---|
| Deleted | src/Providers/TotemRouteServiceProvider.php, routes/api.php, webpack.config.js, src/User.php |
| New | src/Rules/CronExpression.php, src/Rules/JsonFile.php, vite.config.js, tests/TestUser.php |
| Modified | src/Providers/TotemServiceProvider.php, src/TotemModel.php, src/Task.php, src/Result.php, src/Repositories/EloquentTaskRepository.php, src/Notifications/TaskCompleted.php, src/Traits/HasFrequencies.php, src/helpers.php, src/Http/Controllers/*.php (all), src/Http/Requests/TaskRequest.php, src/Http/Requests/ImportRequest.php, src/Http/Controllers/UpcomingTasksController.php, routes/web.php, composer.json, package.json, resources/assets/js/app.js, all Vue components |
| Untouched | Database migrations (all 14 kept as-is) |
php vendor/bin/phpunit green on PHP 8.2, 8.3, 8.4 × Laravel 11, 12npm run build produces public/js/app.js without errorsTOTEM_*)Validator::extend() callsCronExpression::factory() callsNexmoMessage / nexmo channel referencesRouteServiceProvider extensioninline-template usageVue.mixin() global mixinmoment dependencyHow can I help you explore Laravel packages today?