padosoft/laravel-routines-contracts

Shared contracts for padosoft/laravel-routines — the vocabulary a package needs to declare a routine target, with zero runtime dependencies.

Maintainers

Package info

github.com/padosoft/laravel-routines-contracts

pkg:composer/padosoft/laravel-routines-contracts

Transparency log

Statistics

Installs: 104

Dependents: 2

Suggesters: 1

Stars: 1

Open Issues: 0

v1.2.0 2026-08-29 19:04 UTC

This package is auto-updated.

Last update: 2026-08-29 19:06:33 UTC


README

The vocabulary a package needs to declare a routine target, with zero runtime dependencies.

tests Latest Version License

This package is the shared language between padosoft/laravel-routines — the engine that decides when something runs and on whose behalf — and the packages that know how to run it.

It contains interfaces and value objects. No service provider, no migrations, no framework. A package that wants to be a routine target depends on this, not on the engine that will invoke it.

Install

composer require padosoft/laravel-routines-contracts

Requires PHP 8.3+. Nothing else.

The one interface that matters

use Padosoft\Routines\Contracts\Target\RoutineTarget;

final class SendDigestTarget implements RoutineTarget
{
    public function type(): string { return 'digest'; }

    public function descriptor(): TargetDescriptor { /* how the UI draws your form */ }

    public function validate(array $payload): ValidationResult { /* at CREATION, not at 3am */ }

    public function fire(RoutineExecution $execution): TargetResult { /* do the thing */ }
}

Register it from your own service provider. The engine keeps no list of target types — it asks its registry, and the registry only knows what packages have put in it.

This is the same shape as ReviewableSource in laravel-iam-server: whoever orchestrates and whoever knows the domain are two different responsibilities, and keeping them apart is what lets an optional package join without the core learning about it.

Four outcomes, and why the fourth exists

fire() returns a TargetResult carrying one of four outcomes. Three are obvious.

TargetResult::succeeded('Sent 14 digests', cost: 0.08);
TargetResult::failed('SMTP refused the connection');
TargetResult::skipped('Nothing new since the last run');
TargetResult::paused('Needs approval to charge €250', pendingApprovalId: 'apr_1');

paused is the one that carries the design. A routine runs when the user is not there. If it meets something that exceeds the mandate it was created with, neither of the other answers is honest: nothing is broken, so it did not fail; nothing was done, so it did not succeed. It is stopped, and a human is needed.

Without that fourth case, whoever implements a target has to choose between proceeding without permission and failing silently — and both are worse than asking.

The mandate

RoutineMandate is the piece that makes unattended execution defensible.

The ecosystem's security rule says the model proposes and the user confirms on screen, per action. A routine breaks that by definition: at 3am there is no screen. The usual workarounds are both bad — ask for one broad delegation and let it run (the "let the assistant act for me" switch, with a nicer name), or never run anything useful.

The mandate is the third way: a standing consent that is narrower than the interactive one. The step-up confirmation is cryptographically bound not to a transaction but to four things at once:

$mandate = new RoutineMandate(
    targetType:     'flow',
    payloadDigest:  hash('sha256', $canonicalPayload),
    actionClasses:  ['orders.read', 'orders.draft'],   // what it may do unattended
    budgetCeiling:  25.00,
    notAfter:       new DateTimeImmutable('+90 days'),
);

$mandate->digest();              // what the consent signs — change any of the four, it changes
$mandate->covers('orders.pay');  // false → the routine pauses and asks a human

Inside those four the routine acts alone. Anything that exceeds them pauses the run and reaches a human on a channel, with step-up bound to the real parameters of that action.

Two deliberate details, both tested:

  • The digest is canonical. Reordering or duplicating action classes does not change it, and 10 and 10.00 are the same ceiling. Two semantically identical mandates must produce the same digest — otherwise an innocent save would invalidate a valid consent, and the user would be asked again and again, which is the fastest way to teach someone to confirm without reading.
  • covers() is fail-closed. A mandate with no action classes authorizes nothing. An empty array meaning "everything" would turn an oversight into full permission.

What's in the box

Target\RoutineTarget The interface. Four methods.
Target\TargetDescriptor How a target presents itself to a UI that has never heard of it.
Target\TargetResult · TargetOutcome The four outcomes, with cost and resume token.
Target\ValidationResult Payload errors, shaped for a form.
Execution\RoutineExecution The immutable per-fire context: idempotency key, delegated token, budget left, deadline, timezone.
Execution\FireReason Scheduled · manual · catch-up · event · webhook · retry · resumed.
Routine\RoutineRef · RoutineStatus · TriggerKind Identity and lifecycle.
Routine\OverlapPolicy · MissedRunPolicy What to do when a fire is still running, or was missed.
Consent\RoutineMandate · MandateExceeded The standing, bounded consent.

Design notes worth knowing

The target never invents its own idempotency key. It arrives in RoutineExecution, generated once at fire time and stable across retries. A target that generates its own generates a different one on every retry — which is exactly the bug the key was there to prevent. Same for the delegated token, the remaining budget and the deadline: the engine decides, the target receives.

type() is stable forever. It is persisted on every routine and every run. Changing it orphans history, which is evidence: past routines would point at a type that no longer exists, becoming neither runnable nor readable.

Owners are canonical strings, not objects. RoutineRef::$owner is user:usr_123, not a SubjectRef from another package. These contracts depend on nothing, and whoever integrates IAM converts at the boundary. Duplicating a SubjectRef here would be worse than the dependency: two incompatible types for the same concept, across an ecosystem that already has one.

Paused and Suspended are not synonyms. RoutineStatus::Paused is the user's choice and only they undo it; Suspended was imposed by the system — budget exhausted, anomaly detected, owner disabled — and lifts only when the cause is gone. Merging them would mean a user can cancel a security suspension by pressing "resume".

Ecosystem

License

MIT. See LICENSE.