Weave 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
Agents
codetechnl/laravel-taskbridge
claude-code
cursor
scheduling
job-discovery
php-attributes
interfaces
queues
config

For full context on this package, read @README.md. For architecture details, read @docs/architecture.md.

Install
php artisan boost:add-skill codetechnl/laravel-taskbridge

Save this content to: AGENTS.md

# AGENTS.md — laravel-taskbridge

For full context on this package, read @README.md. For architecture details, read @docs/architecture.md.

## Commands

Run after every code change — both must pass before finishing:

```bash
./vendor/bin/pint   # code style (default ruleset)
./vendor/bin/pest   # all tests must pass
```

If `vendor/` is missing, run `composer install` first.

## Git

**Never create commits unless explicitly requested by the user.**

## Rules

**This package is an addition, not a replacement.** It works alongside Laravel's built-in scheduler. Never describe or document it as a replacement for `Kernel::schedule()`.

**`ScheduledJob` interface does not exist.** Any `ShouldQueue` job can be used with TaskBridge. Discovery uses `ShouldQueue` (interface mode) or `#[SchedulableJob]` (attribute mode), the middleware checks `TaskBridge::isRegistered()`. Never re-introduce a `ScheduledJob` interface.

**Interface names are final.** The five optional interfaces are: `RunsConditionally`, `HasGroup`, `HasCustomLabel`, `ReportsTaskOutput`, `HasPredefinedCronExpression`. Do not use or reference the old names (`ConditionalJob`, `GroupedJob`, `LabeledJob`, `ReportsOutput`).

**`#[SchedulableJob]` attribute takes precedence over interfaces for label, group, and cron.** Priority order: attribute → interface → auto-derived default. When both are present the attribute wins. Omitting an attribute parameter falls through to the interface, then to the default. Never change this priority order.

**`JobDiscoverer` has both filtered and unfiltered variants.** `discover()` and `discoverByAttribute()` apply `hasSimpleConstructor()` and are used for registration. `discoverAll()` and `discoverAllByAttribute()` skip that filter and are used by the job picker modal to surface incompatible jobs with a warning. Never add the constructor filter to the `discoverAll` variants — their entire purpose is to return everything including incompatible jobs.

**`JobInspector::getIncompatibleConstructorParams()` describes incompatible params.** Returns an array of human-readable strings like `['$repo: UserRepository']`. Used only for display (the job picker warning). Never use it to gate registration or execution.

**Discovery config lives under `auto_discovery`, not at the root.** The old flat keys `discovery_mode` and `discover` no longer exist. Always use:
```php
config('taskbridge.auto_discovery.mode')   // 'interface' | 'attribute' | null
config('taskbridge.auto_discovery.paths')  // array of directory paths
```

**`ReportsTaskOutput` requires `reportOutput()`.** The interface declares `reportOutput(array $metadata): void` — it is no longer a marker. The `HasJobOutput` trait satisfies it:
```php
class ImportProducts implements ReportsTaskOutput, ShouldQueue
{
    use HasJobOutput; // provides the required reportOutput() implementation
}
```

**`cronExpression()` is declared by `HasPredefinedCronExpression`.** Implementing the interface is optional — jobs without it require the cron to be set in the UI. The Filament resource checks `$instance instanceof HasPredefinedCronExpression` (not `method_exists`).

**`TriggeredBy` enum has 4 cases.** The four cases are: `Scheduler` (`scheduler`, color `gray`), `Manual` (`manual`, color `primary`), `DryRun` (`dry_run`, color `warning`), and `ScheduledOnce` (`scheduled_once`, color `info`). Do not treat `ScheduledOnce` as a missing or future case — it is present and must be handled in any `match` expression over `TriggeredBy`.

**`taskbridge.schedules` entries MUST use array format.** Each entry must be an array with at minimum a `cron` key:
```php
\App\Jobs\SendDailyReport::class => ['cron' => '0 8 * * *', 'arguments' => []],
```
Plain-string shorthand (e.g. `\App\Jobs\SendDailyReport::class => '0 8 * * *'`) is not accepted and will fail validation.

**`identifierFromClass()` enforces a 64-char limit.** If the generated identifier (`prefix-kebab-class-name`) exceeds 64 characters, the bare class-name part (not the prefix) is replaced with `md5($bareClassName)`. The prefix is NOT included in the hash input. If `prefix + "-" + md5` would still exceed 64 characters, a `RuntimeException` is thrown. This means prefixes longer than 31 characters may cause an exception.

**`ImportSchedulesCommand::parseEntry()` and `::validateArguments()` are public static helpers.** These two methods on `ImportSchedulesCommand` encapsulate all entry-level validation logic. When Filament actions (e.g. `ImportSchedulesAction`) need to validate or parse a `schedules` config entry, they must call these static methods — never duplicate the logic in the Filament package.

**Migration `down()` — split into two `Schema::table` calls when dropping a column with a unique index.** SQLite cannot drop a column that has an attached unique index in a single `Schema::table` call. Always drop the index first in one call, then drop the column in a second call:
```php
public function down(): void
{
    Schema::table('taskbridge_jobs', function (Blueprint $table) {
        $table->dropUnique(['run_once_schedule_name']);
    });
    Schema::table('taskbridge_jobs', function (Blueprint $table) {
        $table->dropColumn(['run_once_at', 'run_once_schedule_name']);
    });
}
```

**Always use enum cases in queries** — `status` and `triggered_by` are Eloquent-cast enums:
```php
// correct
ScheduledJobRun::where('status', RunStatus::Failed)->count();
// wrong — do not do this
ScheduledJobRun::where('status', 'failed')->count();
```

**Always resolve model classes via config**, never hardcode them:
```php
// correct
config('taskbridge.models.scheduled_job', ScheduledJob::class)
```

**Never merge the two logging paths.** Queue-worker-triggered runs go through `TaskBridgeServiceProvider::registerQueueListeners()`. Manual/dry runs go through `TaskBridge::run()`.

**JobOutputRegistry — never read `$bag` directly.** Always use `retrieveSuccess()` or `retrieveError()`. Both methods clear the entry automatically.

**EventBridgeDriver::sync() is always upsert.** Never add diffing logic using `ScheduleExpression` — `listSchedules` does not return that field.

**`sync()` never deletes `once-` schedules.** The stale-removal loop skips every name for which `EventBridgeDriver::isOnceScheduleName()` is true. One-time schedules are not recurring jobs, never match an enabled identifier, and self-delete after firing (`ActionAfterCompletion: DELETE`). Without the guard every sync (`enable()`, `overrideCron()`, `resetCron()`, the admin Sync button) wipes all pending one-time schedules. Never remove the guard; never rename `ONCE_PREFIX` without migrating the schedules that already exist.

**`fetchExistingSchedules()` follows `NextToken`.** `listSchedules` returns at most 100 schedules per page. The driver loops until no `NextToken` comes back — never reduce it to a single call, or a group with more than 100 schedules is synced against an arbitrary subset.

**`scheduleOnce(..., log: false)` writes no database rows.** It creates the EventBridge schedule only and returns a transient `ScheduledJobRun` with the schedule name in `output['aws_schedule_name']`. Its fire is not logged either, not even on the recurring row of the same class. Use it for bulk one-time schedules and for unregistered job classes (the queue listener only tracks registered classes, so their pending run row would never complete). Cancel by name via `TaskBridge::getEventBridge()->remove($name)`.

**One-time schedule names are capped at 64 characters.** `EventBridgeDriver::onceScheduleName()` builds `once-{identifier}-{8 random characters}`; an identifier longer than 50 characters is cut to its first 41 characters (trailing dash trimmed) plus 8 hexadecimal characters of its MD5. `identifierFromClass()` alone does not protect the once-name: its 64-character budget leaves no room for the prefix and the suffix. Keep the rule deterministic — the same identifier must always produce the same stem.

**The SQS payload names its schedule under `taskbridge_schedule`** (`EventBridgeDriver::PAYLOAD_SCHEDULE_KEY`, added by `buildJobPayload()` for recurring and one-time schedules alike). The queue listener resolves the job row by that name first (`resolveScheduledJob()`): a `once-` name maps to its `run_once_schedule_name` row only (no row = `log: false` = not logged), a recurring name to the row with that identifier, and only a payload without the key falls back to the class, recurring row first. A one-time row continues its pending run (`startRun()`) instead of creating a second one, and gets `last_run_at`/`last_status` itself. Never drop the key from the payload and never go back to `where('class')->first()` as the primary lookup: a one-time row for the same class would absorb the recurring runs and the pending run would stay `Pending` forever.

**`TaskBridgeFake::fake()` installs the fake — not `enable()`.** A static `enable()` collides with the inherited `TaskBridge::enable(string $jobClass)` and the class fails to load. The fake's driver talks to `FakeSchedulerClient` (`src/Testing/`), reachable via `schedulerClient()`; it records created/updated/deleted schedules and pages `listSchedules()` through `NextToken`.

**`tests/TestCase.php` migrates without rollback.** It calls `Artisan::call('migrate')` instead of `loadMigrationsFrom()`: the in-memory database is new for every test, and the chain's `down()` methods fail on SQLite once rows exist that the older schema cannot hold. Do not switch back to `loadMigrationsFrom()`.

**Never add `SqsParameters` to the EventBridge payload.** `MessageGroupId` is FIFO-only; standard SQS queues reject it.

**The driver method is `getEventBridge()`, not `getDriver()`.** The old `getDriver()` was removed.

**Errors are in `output['message']`, not an `error` column.** That column was removed.

**`disable()` does not call `sync()`.** It calls `$this->eventBridge->remove()` directly.

**`ScheduleExpressionTimezone` applies only to `cron()` and `rate()` expressions.** The `at()` expression (used by `scheduleOnce()`) is always UTC and must not include `ScheduleExpressionTimezone`. The `EventBridgeDriver` conditionally adds the timezone key only when the expression does not start with `at(`. The datetime must be converted to UTC via `->clone()->utc()` before formatting — do not call `->utc()` on the original Carbon instance, as it mutates in place.

**One-time jobs have a `taskbridge_jobs` row** (unless scheduled with `log: false`). `scheduleOnce()` creates a `ScheduledJob` record with `run_once_at` set. These rows are distinct from recurring job rows:
- `$job->isOnce()` — returns `true` when `run_once_at !== null`
- `ScheduledJob::recurring()` scope — `whereNull('run_once_at')` — use this whenever querying recurring jobs (e.g. in `findOrFail`, `enable`, `disable`, middleware lookup)
- Never enable/disable or sync one-time job rows; they are read-only records

**`CronTranslator::describe()` zero-pads hour and minute.** Output must be `"Daily at 08:00"`, never `"Daily at 8:0"`. The implementation uses `str_pad($value, 2, '0', STR_PAD_LEFT)` for both parts.

**`PruneOnceSchedulesJob` exists in this package.** It deletes `taskbridge_jobs` rows where `run_once_at < now()->subDays($retentionDays)`. Constructor: `?int $retentionDays = null` — falls back to `taskbridge.logging.retention_days`. Register it the same way as `PruneRunsJob`.

## Further reading

- @docs/architecture.md — complete class reference, model schemas, enum values, events, migrations
- @config/taskbridge.php — all configuration keys and their defaults
- @tests/ — Pest test suite; fixtures in `tests/Fixtures/`

AGENTS.md — laravel-taskbridge

For full context on this package, read @README.md. For architecture details, read @docs/architecture.md.

Commands

Run after every code change — both must pass before finishing:

./vendor/bin/pint   # code style (default ruleset)
./vendor/bin/pest   # all tests must pass

If vendor/ is missing, run composer install first.

Git

Never create commits unless explicitly requested by the user.

Rules

This package is an addition, not a replacement. It works alongside Laravel's built-in scheduler. Never describe or document it as a replacement for Kernel::schedule().

ScheduledJob interface does not exist. Any ShouldQueue job can be used with TaskBridge. Discovery uses ShouldQueue (interface mode) or #[SchedulableJob] (attribute mode), the middleware checks TaskBridge::isRegistered(). Never re-introduce a ScheduledJob interface.

Interface names are final. The five optional interfaces are: RunsConditionally, HasGroup, HasCustomLabel, ReportsTaskOutput, HasPredefinedCronExpression. Do not use or reference the old names (ConditionalJob, GroupedJob, LabeledJob, ReportsOutput).

#[SchedulableJob] attribute takes precedence over interfaces for label, group, and cron. Priority order: attribute → interface → auto-derived default. When both are present the attribute wins. Omitting an attribute parameter falls through to the interface, then to the default. Never change this priority order.

JobDiscoverer has both filtered and unfiltered variants. discover() and discoverByAttribute() apply hasSimpleConstructor() and are used for registration. discoverAll() and discoverAllByAttribute() skip that filter and are used by the job picker modal to surface incompatible jobs with a warning. Never add the constructor filter to the discoverAll variants — their entire purpose is to return everything including incompatible jobs.

JobInspector::getIncompatibleConstructorParams() describes incompatible params. Returns an array of human-readable strings like ['$repo: UserRepository']. Used only for display (the job picker warning). Never use it to gate registration or execution.

Discovery config lives under auto_discovery, not at the root. The old flat keys discovery_mode and discover no longer exist. Always use:

config('taskbridge.auto_discovery.mode')   // 'interface' | 'attribute' | null
config('taskbridge.auto_discovery.paths')  // array of directory paths

ReportsTaskOutput requires reportOutput(). The interface declares reportOutput(array $metadata): void — it is no longer a marker. The HasJobOutput trait satisfies it:

class ImportProducts implements ReportsTaskOutput, ShouldQueue
{
    use HasJobOutput; // provides the required reportOutput() implementation
}

cronExpression() is declared by HasPredefinedCronExpression. Implementing the interface is optional — jobs without it require the cron to be set in the UI. The Filament resource checks $instance instanceof HasPredefinedCronExpression (not method_exists).

TriggeredBy enum has 4 cases. The four cases are: Scheduler (scheduler, color gray), Manual (manual, color primary), DryRun (dry_run, color warning), and ScheduledOnce (scheduled_once, color info). Do not treat ScheduledOnce as a missing or future case — it is present and must be handled in any match expression over TriggeredBy.

taskbridge.schedules entries MUST use array format. Each entry must be an array with at minimum a cron key:

\App\Jobs\SendDailyReport::class => ['cron' => '0 8 * * *', 'arguments' => []],

Plain-string shorthand (e.g. \App\Jobs\SendDailyReport::class => '0 8 * * *') is not accepted and will fail validation.

identifierFromClass() enforces a 64-char limit. If the generated identifier (prefix-kebab-class-name) exceeds 64 characters, the bare class-name part (not the prefix) is replaced with md5($bareClassName). The prefix is NOT included in the hash input. If prefix + "-" + md5 would still exceed 64 characters, a RuntimeException is thrown. This means prefixes longer than 31 characters may cause an exception.

ImportSchedulesCommand::parseEntry() and ::validateArguments() are public static helpers. These two methods on ImportSchedulesCommand encapsulate all entry-level validation logic. When Filament actions (e.g. ImportSchedulesAction) need to validate or parse a schedules config entry, they must call these static methods — never duplicate the logic in the Filament package.

Migration down() — split into two Schema::table calls when dropping a column with a unique index. SQLite cannot drop a column that has an attached unique index in a single Schema::table call. Always drop the index first in one call, then drop the column in a second call:

public function down(): void
{
    Schema::table('taskbridge_jobs', function (Blueprint $table) {
        $table->dropUnique(['run_once_schedule_name']);
    });
    Schema::table('taskbridge_jobs', function (Blueprint $table) {
        $table->dropColumn(['run_once_at', 'run_once_schedule_name']);
    });
}

Always use enum cases in queries — status and triggered_by are Eloquent-cast enums:

// correct
ScheduledJobRun::where('status', RunStatus::Failed)->count();
// wrong — do not do this
ScheduledJobRun::where('status', 'failed')->count();

Always resolve model classes via config, never hardcode them:

// correct
config('taskbridge.models.scheduled_job', ScheduledJob::class)

Never merge the two logging paths. Queue-worker-triggered runs go through TaskBridgeServiceProvider::registerQueueListeners(). Manual/dry runs go through TaskBridge::run().

JobOutputRegistry — never read $bag directly. Always use retrieveSuccess() or retrieveError(). Both methods clear the entry automatically.

EventBridgeDriver::sync() is always upsert. Never add diffing logic using ScheduleExpression — listSchedules does not return that field.

sync() never deletes once- schedules. The stale-removal loop skips every name for which EventBridgeDriver::isOnceScheduleName() is true. One-time schedules are not recurring jobs, never match an enabled identifier, and self-delete after firing (ActionAfterCompletion: DELETE). Without the guard every sync (enable(), overrideCron(), resetCron(), the admin Sync button) wipes all pending one-time schedules. Never remove the guard; never rename ONCE_PREFIX without migrating the schedules that already exist.

fetchExistingSchedules() follows NextToken. listSchedules returns at most 100 schedules per page. The driver loops until no NextToken comes back — never reduce it to a single call, or a group with more than 100 schedules is synced against an arbitrary subset.

scheduleOnce(..., log: false) writes no database rows. It creates the EventBridge schedule only and returns a transient ScheduledJobRun with the schedule name in output['aws_schedule_name']. Its fire is not logged either, not even on the recurring row of the same class. Use it for bulk one-time schedules and for unregistered job classes (the queue listener only tracks registered classes, so their pending run row would never complete). Cancel by name via TaskBridge::getEventBridge()->remove($name).

One-time schedule names are capped at 64 characters. EventBridgeDriver::onceScheduleName() builds once-{identifier}-{8 random characters}; an identifier longer than 50 characters is cut to its first 41 characters (trailing dash trimmed) plus 8 hexadecimal characters of its MD5. identifierFromClass() alone does not protect the once-name: its 64-character budget leaves no room for the prefix and the suffix. Keep the rule deterministic — the same identifier must always produce the same stem.

The SQS payload names its schedule under taskbridge_schedule (EventBridgeDriver::PAYLOAD_SCHEDULE_KEY, added by buildJobPayload() for recurring and one-time schedules alike). The queue listener resolves the job row by that name first (resolveScheduledJob()): a once- name maps to its run_once_schedule_name row only (no row = log: false = not logged), a recurring name to the row with that identifier, and only a payload without the key falls back to the class, recurring row first. A one-time row continues its pending run (startRun()) instead of creating a second one, and gets last_run_at/last_status itself. Never drop the key from the payload and never go back to where('class')->first() as the primary lookup: a one-time row for the same class would absorb the recurring runs and the pending run would stay Pending forever.

TaskBridgeFake::fake() installs the fake — not enable(). A static enable() collides with the inherited TaskBridge::enable(string $jobClass) and the class fails to load. The fake's driver talks to FakeSchedulerClient (src/Testing/), reachable via schedulerClient(); it records created/updated/deleted schedules and pages listSchedules() through NextToken.

tests/TestCase.php migrates without rollback. It calls Artisan::call('migrate') instead of loadMigrationsFrom(): the in-memory database is new for every test, and the chain's down() methods fail on SQLite once rows exist that the older schema cannot hold. Do not switch back to loadMigrationsFrom().

Never add SqsParameters to the EventBridge payload. MessageGroupId is FIFO-only; standard SQS queues reject it.

The driver method is getEventBridge(), not getDriver(). The old getDriver() was removed.

Errors are in output['message'], not an error column. That column was removed.

disable() does not call sync(). It calls $this->eventBridge->remove() directly.

ScheduleExpressionTimezone applies only to cron() and rate() expressions. The at() expression (used by scheduleOnce()) is always UTC and must not include ScheduleExpressionTimezone. The EventBridgeDriver conditionally adds the timezone key only when the expression does not start with at(. The datetime must be converted to UTC via ->clone()->utc() before formatting — do not call ->utc() on the original Carbon instance, as it mutates in place.

One-time jobs have a taskbridge_jobs row (unless scheduled with log: false). scheduleOnce() creates a ScheduledJob record with run_once_at set. These rows are distinct from recurring job rows:

  • $job->isOnce() — returns true when run_once_at !== null
  • ScheduledJob::recurring() scope — whereNull('run_once_at') — use this whenever querying recurring jobs (e.g. in findOrFail, enable, disable, middleware lookup)
  • Never enable/disable or sync one-time job rows; they are read-only records

CronTranslator::describe() zero-pads hour and minute. Output must be "Daily at 08:00", never "Daily at 8:0". The implementation uses str_pad($value, 2, '0', STR_PAD_LEFT) for both parts.

PruneOnceSchedulesJob exists in this package. It deletes taskbridge_jobs rows where run_once_at < now()->subDays($retentionDays). Constructor: ?int $retentionDays = null — falls back to taskbridge.logging.retention_days. Register it the same way as PruneRunsJob.

Further reading

  • @docs/architecture.md — complete class reference, model schemas, enum values, events, migrations
  • @config/taskbridge.php — all configuration keys and their defaults
  • @tests/ — Pest test suite; fixtures in tests/Fixtures/
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.
mrnewport/laravel-plaid
mrnewport/laravel-repo
mrnewport/laravel-docsign
mrnewport/laravel-stow
mrnewport/laravel-flow
mrnewport/laravel-priceable
mrnewport/laravel-nestedterms
fredmeng/laravel-apidoc-generator
itstructure/laravel-multilingual-tools
pdustdar/elasticsearch
markupai/php-sdk
rlacerda83/lumem-microservice-shipment
uni-method/symfony-lib
tacticmedia/scim-server-bundle
neika-ai/symfony-bundle
mattleads/agent-publishing-bundle
glavweb/framework-bundle
glavweb/extra-bundle
glavweb/api-bundle
glavweb/admin-bundle