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+.
For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
Goal: Bring every outdated pattern in Laravel Totem up to Laravel 11+ / PHP 8.2+ / Vue 3 conventions without rewriting the architecture.
Architecture: Surgical changes only — fix deprecated APIs, remove global constants, replace dead build tooling, upgrade Vue 2 → Vue 3. Every change has a test. Migrations are untouched.
Tech Stack: PHP 8.2+, Laravel 11/12, Orchestra Testbench, Vue 3 Composition API, Vite 5, Day.js, UIKit 3
src/tests/Feature/ and tests/TestCase.phpresources/assets/js/public/js/app.jsresources/views/routes/web.phpconfig/totem.phpphp vendor/bin/phpunitnpm run build (after Task 14)TotemModel to configWhy: TOTEM_TABLE_PREFIX and TOTEM_DATABASE_CONNECTION are global PHP constants defined at runtime, which pollute the global namespace and make testing fragile.
Files:
src/TotemModel.phpsrc/Providers/TotemServiceProvider.phptests/Feature/TotemModelTest.php (create)Step 1: Write the failing test
Create tests/Feature/TotemModelTest.php:
<?php
namespace Studio\Totem\Tests\Feature;
use Studio\Totem\Task;
use Studio\Totem\Tests\TestCase;
class TotemModelTest extends TestCase
{
public function test_model_uses_configured_table_prefix(): void
{
config(['totem.table_prefix' => 'custom_']);
$task = new Task;
$this->assertEquals('custom_tasks', $task->getTable());
}
public function test_model_uses_empty_prefix_by_default(): void
{
config(['totem.table_prefix' => '']);
$task = new Task;
$this->assertEquals('tasks', $task->getTable());
}
public function test_model_uses_configured_database_connection(): void
{
config(['totem.database_connection' => 'sqlite']);
$task = new Task;
$this->assertEquals('sqlite', $task->getConnectionName());
}
}
Step 2: Run test to verify it fails
php vendor/bin/phpunit tests/Feature/TotemModelTest.php
Expected: FAIL — getConnectionName() doesn't exist, and getTable() currently uses the constant.
Step 3: Update src/TotemModel.php
Replace the entire file with:
<?php
namespace Studio\Totem;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Str;
class TotemModel extends Model
{
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::startsWith($table, $prefix) ? $table : $prefix.$table;
}
}
Step 4: Remove the three define() calls from src/Providers/TotemServiceProvider.php
Remove these lines from register():
if (! defined('TOTEM_PATH')) {
define('TOTEM_PATH', realpath(__DIR__.'/../../'));
}
if (! defined('TOTEM_TABLE_PREFIX')) {
define('TOTEM_TABLE_PREFIX', config('totem.table_prefix'));
}
if (! defined('TOTEM_DATABASE_CONNECTION')) {
define('TOTEM_DATABASE_CONNECTION', config('totem.database_connection', config('database.default')));
}
Replace TOTEM_PATH in defineAssetPublishing():
// Before
TOTEM_PATH.'/public/js' => public_path('vendor/totem/js'),
// After
__DIR__.'/../../public/js' => public_path('vendor/totem/js'),
Do the same for all three TOTEM_PATH occurrences in defineAssetPublishing().
Step 5: Replace TOTEM_TABLE_PREFIX in src/Result.php
// Before
->whereColumn('task_id', TOTEM_TABLE_PREFIX.'tasks.id')
// After (two occurrences)
->whereColumn('task_id', config('totem.table_prefix', '').'tasks.id')
Step 6: Replace TOTEM_TABLE_PREFIX in src/Repositories/EloquentTaskRepository.php
// Before
return (new Task)->select(TOTEM_TABLE_PREFIX.'tasks.*')
// After
return (new Task)->select(config('totem.table_prefix', '').'tasks.*')
Step 7: Run the full test suite
php vendor/bin/phpunit
Expected: All 46 existing tests pass + 3 new tests pass = 49 total.
Step 8: Commit
git add src/TotemModel.php src/Providers/TotemServiceProvider.php src/Result.php src/Repositories/EloquentTaskRepository.php tests/Feature/TotemModelTest.php
git commit -m "refactor: replace global PHP constants with config() calls"
CronExpression::factory() → new CronExpression()Why: The static factory() method was removed from dragonmantank/cron-expression v3.
Files:
src/Task.php:75src/Http/Controllers/UpcomingTasksController.php:51Step 1: Update src/Task.php
// Before (line 75)
return CronExpression::factory($this->getCronExpression())->getNextRunDate()->format('Y-m-d H:i:s');
// After
return (new CronExpression($this->getCronExpression()))->getNextRunDate()->format('Y-m-d H:i:s');
Step 2: Update src/Http/Controllers/UpcomingTasksController.php
// Before (line 51)
$cron = CronExpression::factory($task->getCronExpression());
// After
$cron = new CronExpression($task->getCronExpression());
Step 3: Run tests
php vendor/bin/phpunit
Expected: All 49 tests pass (no regression).
Step 4: Commit
git add src/Task.php src/Http/Controllers/UpcomingTasksController.php
git commit -m "fix: replace deprecated CronExpression::factory() with new CronExpression()"
CronExpression validation rule classWhy: Validator::extend('cron_expression', ...) is deprecated. Replace with a first-class Rule object.
Files:
src/Rules/CronExpression.phptests/Feature/Rules/CronExpressionRuleTest.phpStep 1: Write the failing test
Create tests/Feature/Rules/CronExpressionRuleTest.php:
<?php
namespace Studio\Totem\Tests\Feature\Rules;
use Closure;
use Studio\Totem\Rules\CronExpression;
use Studio\Totem\Tests\TestCase;
class CronExpressionRuleTest extends TestCase
{
public function test_valid_cron_expression_passes(): void
{
$rule = new CronExpression;
$failed = false;
$rule->validate('expression', '* * * * *', function () use (&$failed) {
$failed = true;
});
$this->assertFalse($failed);
}
public function test_invalid_cron_expression_fails(): void
{
$rule = new CronExpression;
$message = null;
$rule->validate('expression', 'not-a-cron', function (string $msg) use (&$message) {
$message = $msg;
});
$this->assertNotNull($message);
$this->assertStringContainsString('valid cron expression', $message);
}
public function test_five_part_expression_passes(): void
{
$rule = new CronExpression;
$failed = false;
$rule->validate('expression', '0 9 * * 1-5', function () use (&$failed) {
$failed = true;
});
$this->assertFalse($failed);
}
}
Step 2: Run test to verify it fails
php vendor/bin/phpunit tests/Feature/Rules/CronExpressionRuleTest.php
Expected: FAIL — class does not exist.
Step 3: Create src/Rules/CronExpression.php
<?php
namespace Studio\Totem\Rules;
use Closure;
use Cron\CronExpression as CronParser;
use Illuminate\Contracts\Validation\ValidationRule;
class CronExpression implements ValidationRule
{
public function validate(string $attribute, mixed $value, Closure $fail): void
{
if (! CronParser::isValidExpression($value)) {
$fail('This is not a valid cron expression.');
}
}
}
Step 4: Run tests
php vendor/bin/phpunit tests/Feature/Rules/CronExpressionRuleTest.php
Expected: 3 tests pass.
Step 5: Run full suite
php vendor/bin/phpunit
Expected: All pass.
Step 6: Commit
git add src/Rules/CronExpression.php tests/Feature/Rules/CronExpressionRuleTest.php
git commit -m "feat: add CronExpression validation rule class"
JsonFile validation rule classWhy: Validator::extend('json_file', ...) is deprecated.
Files:
src/Rules/JsonFile.phptests/Feature/Rules/JsonFileRuleTest.phpStep 1: Write the failing test
Create tests/Feature/Rules/JsonFileRuleTest.php:
<?php
namespace Studio\Totem\Tests\Feature\Rules;
use Illuminate\Http\UploadedFile;
use Studio\Totem\Rules\JsonFile;
use Studio\Totem\Tests\TestCase;
class JsonFileRuleTest extends TestCase
{
public function test_json_file_passes(): void
{
$rule = new JsonFile;
$file = UploadedFile::fake()->create('tasks.json', 100, 'application/json');
$failed = false;
$rule->validate('tasks', $file, function () use (&$failed) {
$failed = true;
});
$this->assertFalse($failed);
}
public function test_non_json_file_fails(): void
{
$rule = new JsonFile;
$file = UploadedFile::fake()->create('tasks.csv', 100, 'text/csv');
$message = null;
$rule->validate('tasks', $file, function (string $msg) use (&$message) {
$message = $msg;
});
$this->assertNotNull($message);
}
}
Step 2: Run to verify failure
php vendor/bin/phpunit tests/Feature/Rules/JsonFileRuleTest.php
Expected: FAIL — class does not exist.
Step 3: Create src/Rules/JsonFile.php
<?php
namespace Studio\Totem\Rules;
use Closure;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Http\UploadedFile;
class JsonFile implements ValidationRule
{
public function validate(string $attribute, mixed $value, Closure $fail): void
{
if (! $value instanceof UploadedFile || $value->getClientOriginalExtension() !== 'json') {
$fail('The :attribute must be a JSON file.');
}
}
}
Step 4: Run tests
php vendor/bin/phpunit tests/Feature/Rules/JsonFileRuleTest.php
Expected: 2 tests pass.
Step 5: Run full suite
php vendor/bin/phpunit
Expected: All pass.
Step 6: Commit
git add src/Rules/JsonFile.php tests/Feature/Rules/JsonFileRuleTest.php
git commit -m "feat: add JsonFile validation rule class"
Validator::extend()Why: Remove the two deprecated Validator::extend() calls from the service provider.
Files:
src/Providers/TotemServiceProvider.phpsrc/Http/Requests/TaskRequest.phpsrc/Http/Requests/ImportRequest.phpStep 1: Update src/Http/Requests/TaskRequest.php
Add the import at the top:
use Studio\Totem\Rules\CronExpression;
Change the rules() method:
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'description' => ['required'],
'command' => ['required'],
'expression' => ['nullable', 'required_if:type,expression', new CronExpression],
'frequencies' => ['required_if:type,frequency', 'array'],
'notification_email_address' => ['nullable', 'email'],
'notification_phone_number' => ['nullable', 'digits_between:11,13'],
'notification_slack_webhook' => ['nullable', 'url'],
];
}
public function messages(): array
{
return [
'description.required' => 'Task description is required',
'command.required' => 'Please select a command',
'expression.required_if' => 'Cron Expression is required if task type is expression',
'frequencies.required_if' => 'At least one frequency is required',
'frequencies.array' => 'At least one frequency is required',
'notification_email_address.email' => 'Email address is not valid',
'notification_phone_number.digits_between' => 'Phone number should be between 11 and 13 digits including country code',
'notification_slack_webhook.url' => 'Slack Webhook must be a valid url',
];
}
public function validationData(): array
{
if ($this->input('type') == 'frequency') {
$this->merge(['expression' => null]);
}
return $this->all();
}
Step 2: Update src/Http/Requests/ImportRequest.php
Read the file first to see its current content, then add the JsonFile rule to whatever validation it performs. If it extends FormRequest with a file rule, replace 'json_file' string rule with new JsonFile.
Add import: use Studio\Totem\Rules\JsonFile;
Change any 'json_file' string rule to new JsonFile.
Step 3: Remove Validator::extend() calls from src/Providers/TotemServiceProvider.php
Remove from boot():
// Remove these two blocks entirely:
Validator::extend('cron_expression', function ($attribute, $value, $parameters, $validator) {
return CronExpression::isValidExpression($value);
});
Validator::extend('json_file', function ($attribute, UploadedFile $value, $validator) {
return $value->getClientOriginalExtension() == 'json';
});
Also remove these now-unused imports from the service provider:
use Cron\CronExpression;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Validator;
Step 4: Run full test suite
php vendor/bin/phpunit
Expected: All tests pass (the CreateTask and EditTask tests exercise validation).
Step 5: Commit
git add src/Providers/TotemServiceProvider.php src/Http/Requests/TaskRequest.php src/Http/Requests/ImportRequest.php
git commit -m "refactor: replace Validator::extend() with ValidationRule classes"
Why: NexmoMessage and the nexmo channel were removed from Laravel. Vonage is the renamed successor.
Files:
src/Notifications/TaskCompleted.phpcomposer.jsontests/Feature/VonageNotificationTest.phpStep 1: Write the failing test
Create tests/Feature/VonageNotificationTest.php:
<?php
namespace Studio\Totem\Tests\Feature;
use Studio\Totem\Notifications\TaskCompleted;
use Studio\Totem\Task;
use Studio\Totem\Tests\TestCase;
class VonageNotificationTest extends TestCase
{
public function test_vonage_channel_used_when_phone_number_set(): void
{
$task = Task::factory()->create(['notification_phone_number' => '18001234567']);
$notification = new TaskCompleted('Task output here');
$channels = $notification->via($task);
$this->assertContains('vonage', $channels);
$this->assertNotContains('nexmo', $channels);
}
public function test_no_vonage_channel_when_no_phone_number(): void
{
$task = Task::factory()->create(['notification_phone_number' => null]);
$notification = new TaskCompleted('output');
$channels = $notification->via($task);
$this->assertNotContains('vonage', $channels);
$this->assertNotContains('nexmo', $channels);
}
public function test_to_vonage_returns_correct_content(): void
{
$task = Task::factory()->create(['description' => 'My Task', 'notification_phone_number' => '18001234567']);
$notification = new TaskCompleted('output');
$message = $notification->toVonage($task);
$this->assertStringContainsString('My Task', $message->content);
}
}
Step 2: Run to verify failure
php vendor/bin/phpunit tests/Feature/VonageNotificationTest.php
Expected: FAIL — vonage channel not in use, toVonage() doesn't exist.
Step 3: Update src/Notifications/TaskCompleted.php
Replace the entire file:
<?php
namespace Studio\Totem\Notifications;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Messages\SlackAttachment;
use Illuminate\Notifications\Messages\SlackMessage;
use Illuminate\Notifications\Messages\VonageMessage;
use Illuminate\Notifications\Notification;
class TaskCompleted extends Notification implements ShouldQueue
{
use Queueable;
public function __construct(private readonly string $output) {}
public function via(mixed $notifiable): array
{
$channels = [];
if ($notifiable->notification_email_address) {
$channels[] = 'mail';
}
if ($notifiable->notification_phone_number) {
$channels[] = 'vonage';
}
if ($notifiable->notification_slack_webhook) {
$channels[] = 'slack';
}
return $channels;
}
public function toMail(mixed $notifiable): MailMessage
{
return (new MailMessage)
->subject($notifiable->description)
->greeting('Hi,')
->line("{$notifiable->description} just finished running.")
->line($this->output);
}
public function toVonage(mixed $notifiable): VonageMessage
{
return (new VonageMessage)
->content($notifiable->description.' just finished running.');
}
public function toSlack(mixed $notifiable): SlackMessage
{
return (new SlackMessage)
->content(config('app.name'))
->attachment(function (SlackAttachment $attachment) use ($notifiable) {
$attachment
->title('Totem Task')
->content($notifiable->description.' just finished running.');
});
}
}
Step 4: Update composer.json suggest section
Change:
"suggest": {
"nexmo/client": "Required for sms notifications."
}
To:
"suggest": {
"laravel/vonage-notification-channel": "Required for SMS notifications via Vonage."
}
Step 5: Update Task.php — remove routeNotificationForNexmo()
In src/Task.php, rename the nexmo routing method:
// Before
public function routeNotificationForNexmo(): string
{
return $this->notification_phone_number;
}
// After
public function routeNotificationForVonage(): string
{
return $this->notification_phone_number;
}
Step 6: Run tests
php vendor/bin/phpunit tests/Feature/VonageNotificationTest.php
Expected: 3 tests pass.
Step 7: Run full suite
php vendor/bin/phpunit
Expected: All pass.
Step 8: Commit
git add src/Notifications/TaskCompleted.php src/Task.php composer.json tests/Feature/VonageNotificationTest.php
git commit -m "feat: replace deprecated Nexmo channel with Vonage"
RouteServiceProvider with inline route registrationWhy: Illuminate\Foundation\Support\Providers\RouteServiceProvider and the $namespace / ->namespace() pattern were removed in Laravel 11. Routes should use tuple syntax [Controller::class, 'method'].
Files:
src/Providers/TotemRouteServiceProvider.phpsrc/Providers/TotemServiceProvider.phproutes/web.phproutes/api.phpStep 1: Run existing route tests first to establish baseline
php vendor/bin/phpunit tests/Feature/ViewDashboardTest.php tests/Feature/ViewTaskTest.php tests/Feature/CreateTaskTest.php
Expected: All pass.
Step 2: Update routes/web.php
Replace the entire file:
<?php
use Illuminate\Support\Facades\Route;
use Studio\Totem\Http\Controllers\ActiveTasksController;
use Studio\Totem\Http\Controllers\DashboardController;
use Studio\Totem\Http\Controllers\ExecuteTasksController;
use Studio\Totem\Http\Controllers\ExportTasksController;
use Studio\Totem\Http\Controllers\ImportTasksController;
use Studio\Totem\Http\Controllers\TasksController;
use Studio\Totem\Http\Controllers\UpcomingTasksController;
Route::get('/', [DashboardController::class, 'index'])->name('totem.dashboard');
Route::prefix('tasks')->group(function () {
Route::get('/', [TasksController::class, 'index'])->name('totem.tasks.all');
Route::get('create', [TasksController::class, 'create'])->name('totem.task.create');
Route::post('create', [TasksController::class, 'store']);
Route::get('export', [ExportTasksController::class, 'index'])->name('totem.tasks.export');
Route::post('import', [ImportTasksController::class, 'index'])->name('totem.tasks.import');
Route::get('upcoming', [UpcomingTasksController::class, 'index'])->name('totem.upcoming');
Route::get('upcoming/events', [UpcomingTasksController::class, 'events'])->name('totem.upcoming.events');
Route::get('{totemTask}', [TasksController::class, 'view'])->name('totem.task.view');
Route::get('{totemTask}/edit', [TasksController::class, 'edit'])->name('totem.task.edit');
Route::post('{totemTask}/edit', [TasksController::class, 'update']);
Route::delete('{totemTask}', [TasksController::class, 'destroy'])->name('totem.task.delete');
Route::post('status', [ActiveTasksController::class, 'store'])->name('totem.task.activate');
Route::delete('status/{totemTask}', [ActiveTasksController::class, 'destroy'])->name('totem.task.deactivate');
Route::get('{totemTask}/execute', [ExecuteTasksController::class, 'index'])->name('totem.task.execute');
});
Step 3: Update src/Providers/TotemServiceProvider.php
In register(), remove:
$this->app->register(TotemRouteServiceProvider::class);
Add this import at top of file:
use Illuminate\Support\Facades\Route;
Remove the use Studio\Totem\Providers\TotemRouteServiceProvider; import.
Add route registration to boot(). Add BEFORE the existing $this->registerResources() call:
public function boot(): void
{...
How can I help you explore Laravel packages today?