Weave Code
Code Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Laravel Totem Laravel Package

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+.

View on GitHub
Deep Wiki
Context7

Laravel Totem Modernization Implementation Plan

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


Context: Key File Locations

  • Source PHP: src/
  • Tests: tests/Feature/ and tests/TestCase.php
  • Vue components: resources/assets/js/
  • Published JS bundle: public/js/app.js
  • Views: resources/views/
  • Routes: routes/web.php
  • Config: config/totem.php
  • Run tests: php vendor/bin/phpunit
  • Run build: npm run build (after Task 14)

Task 1: Remove global constants — migrate TotemModel to config

Why: TOTEM_TABLE_PREFIX and TOTEM_DATABASE_CONNECTION are global PHP constants defined at runtime, which pollute the global namespace and make testing fragile.

Files:

  • Modify: src/TotemModel.php
  • Modify: src/Providers/TotemServiceProvider.php
  • Test: tests/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"

Task 2: Fix deprecated CronExpression::factory()new CronExpression()

Why: The static factory() method was removed from dragonmantank/cron-expression v3.

Files:

  • Modify: src/Task.php:75
  • Modify: src/Http/Controllers/UpcomingTasksController.php:51

Step 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()"

Task 3: Create CronExpression validation rule class

Why: Validator::extend('cron_expression', ...) is deprecated. Replace with a first-class Rule object.

Files:

  • Create: src/Rules/CronExpression.php
  • Create: tests/Feature/Rules/CronExpressionRuleTest.php

Step 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"

Task 4: Create JsonFile validation rule class

Why: Validator::extend('json_file', ...) is deprecated.

Files:

  • Create: src/Rules/JsonFile.php
  • Create: tests/Feature/Rules/JsonFileRuleTest.php

Step 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"

Task 5: Wire new rules into requests, remove Validator::extend()

Why: Remove the two deprecated Validator::extend() calls from the service provider.

Files:

  • Modify: src/Providers/TotemServiceProvider.php
  • Modify: src/Http/Requests/TaskRequest.php
  • Modify: src/Http/Requests/ImportRequest.php

Step 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"

Task 6: Replace Nexmo with Vonage notification channel

Why: NexmoMessage and the nexmo channel were removed from Laravel. Vonage is the renamed successor.

Files:

  • Modify: src/Notifications/TaskCompleted.php
  • Modify: composer.json
  • Create: tests/Feature/VonageNotificationTest.php

Step 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"

Task 7: Replace RouteServiceProvider with inline route registration

Why: Illuminate\Foundation\Support\Providers\RouteServiceProvider and the $namespace / ->namespace() pattern were removed in Laravel 11. Routes should use tuple syntax [Controller::class, 'method'].

Files:

  • Delete: src/Providers/TotemRouteServiceProvider.php
  • Modify: src/Providers/TotemServiceProvider.php
  • Modify: routes/web.php
  • Delete: routes/api.php

Step 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
{...
Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
jayeshmepani/jpl-moshier-ephemeris-php
elnasnato/laraliveui
labrodev/rest-sdk
sampaui/sampaui
babelqueue/php-sdk
facebook/capi-param-builder-php
babelqueue/symfony
hamzi/corewatch
minionfactory/raw-hydrator
hexters/coinpayment
rjcodes/rjcms
act-training/laravel-permissions-manager
alimarchal/laravel-chart-of-accounts
babenkoivan/elastic-scout-driver
mkwebdesign/filament-watchdog-v5
renatomarinho/laravel-page-speed
zedmagdy/filament-business-hours
renatovdemoura/blade-elements-ui
devgeek/beacon-admin
benjamin-rqt/data-watcher-bundle