Reference
What a rule can return
| In the rule | Recorded | What happens next |
|---|---|---|
return self::SUCCESS; (or return nothing) |
SUCCESS (1) |
the next pipe runs |
return self::FAIL; |
FAIL (2) |
the next pipe runs; no error record is added |
return self::FATAL_FAIL; |
FATAL_FAIL (3) |
the next pipe runs: only the value is recorded, with no fatal record, so hasFatalErrors() stays false. To stop, call fatal() |
return $this->skip('why'); |
SKIP (4), and the reason |
the next pipe runs |
$this->error('why'); |
FAIL (2), and an error record |
onError() runs, then the next pipe runs (unless errors are fatal, see below) |
$this->fatal('why'); |
FATAL_FAIL (3), and a fatal record |
nothing after it runs: by default the rest of the flow, with fatalsStopThisHubOnly() the rest of this hub |
any other exception or PHP error, except a PipeRuleFatal (handled as fatal()) and the three below |
FAIL (2), and an error record |
as error() |
A closure pipe's result is whatever it returns (nothing, or the subject itself, counts as SUCCESS). error() and fatal() records point at the line in your rule that called them. Three exceptions are not failures and are never recorded as FAIL: PipeRuleRedirect and PipeRuleLoadView hand control back to your controller, and PipeHubException, a configuration error, is thrown straight out of the flow.
Stopping, in one table
How far a stop reaches:
| On the hub | A rule calls fatal() |
A rule calls error(), or throws an unexpected exception |
|---|---|---|
| (default) | the whole flow stops: the rest of this hub, every later hub, this hub's then() and the manager's then() do not run; the hub's onFatal() runs, then the manager's |
recorded as FAIL; the hub's onError() runs, then the manager's; the next pipe runs (unless an onError() hook throws, see onError() and onFatal()) |
->fatalsStopThisHubOnly() |
this hub stops: the rest of it and its then() do not run, and its onFatal() runs (not the manager's); the next hub runs, and the manager's then() still runs |
as default |
->errorsAreFatal() |
as default | handled as a fatal() (recorded as FATAL_FAIL, sent to onFatal() instead of onError()), so the whole flow stops |
| both | as fatalsStopThisHubOnly() |
handled as a fatal(), so this hub stops |
How the stop reaches the caller, set on the manager (or on a hub run on its own; inside a manager a hub's own setting is ignored):
| Default | ->throwFatals() |
|
|---|---|---|
| a fatal stops the flow | sendIt() returns the envelope; hasFatalErrors() is true |
sendIt() throws the same PipeRuleFatal, after it is logged, onFatal() runs, then() does not run and the log is persisted as configured (a redirect, view or configuration error thrown by onFatal() leaves instead) |
| a fatal is contained to its hub | the flow carries on | the flow carries on, and nothing is thrown |
| a redirect, view or configuration error | thrown from sendIt() |
thrown from sendIt() |
"Exception" means any Throwable, PHP errors such as TypeError included. A rule with protected $errorsAreFatal = true; handles its own errors and exceptions as fatals, as if its hub used errorsAreFatal(). Redirects, views and configuration errors (PipeHubException) are never handled as errors or fatals: wherever they are thrown, they leave sendIt() as themselves (a hub or manager may write a configuration error to the log first). A then() callback, the hub's or the manager's, is handled like a pipe: an exception thrown in it is an error (recorded, sent to onError(), and the flow carries on), or a fatal under the hub's errorsAreFatal(); a PipeRuleFatal thrown in it is a fatal. A skipIf() callback or an event listener that throws stops the hub as a fatal, which reaches as far as any other. What a hook throws: a redirect, view or PipeHubException leaves sendIt() as itself; a PipeRuleFatal from onError() is a fatal; any other exception from onError() stops the hub as a fatal wrapping it; any other exception from onFatal() is recorded as a fatal and the stop carries on as it would have. A callback parameter named $subject gets the envelope, and a hook parameter named $exception gets the exception, by name; otherwise the envelope goes to the first parameter and, for hooks, the exception to the second, whatever they are called. Either way that happens only when the parameter's declared type accepts the value (an untyped parameter always does); a hook parameter typed as an exception class, whether it is named $exception or is the second parameter, always gets the real exception, so a mismatch is a PHP TypeError, never a different exception built for it. The fatal thrown by throwFatals() is the same PipeRuleFatal instance that was raised, so getPrevious() is the error or exception behind a fatal from errorsAreFatal(), and its subject() is set to the envelope before it is thrown. Everything above that "leaves sendIt()" (a fatal, a redirect, a view or a configuration error) assumes saving the log succeeds: the log is saved as the run ends, and an exception from the persistence strategy takes the place of whatever was leaving.
Skipping, in one table
| What runs | What is logged | then() |
|
|---|---|---|---|
->skipIf(fn ($s) => …) returns true |
no rule executes | every rule, as SKIP with the reason (plus a PipeHub:event line when a skip event is set) |
runs |
->skip() |
nothing in the hub | one "Skipping hub" line (plus a PipeHub:event line when a skip event is set) |
does not run |
$envelope->skipAll() (from a rule or callback) |
nothing after that point, in this hub or any later hub | every remaining rule, as SKIP |
runs |
a rule returns $this->skip('…') |
the rule ran and decided to skip | that rule, as SKIP with the reason |
runs |
Rule options
public static $runFunction = 'check';runs a method with another name.public static $saveResultAs = 'title_verdict';also stores the rule's result under that key.protected $errorsAreFatal = true;turns the rule'serror()calls and unexpected exceptions into fatals.$this->foo = $value/$this->foo, and$this->setFooResult($value)/$this->getFooResult(), write and readfooon the envelope while the rule runs.- Rule results are stored on the envelope under the class name:
$envelope->get(TidyTitle::class). addPipe()andaddPipes()throw aPipeHubExceptionfor a class that does not exist or is not aPipeRule, so a typo never quietly drops a step.
Reading the log
Every record is an array with a time, a key (usually the rule class, a closure's file and line, or a PipeHub:… / PipeManager:… name), a type (info, warning, error, fatal, timerStart, timerEnd, returnValue), its data and a context array (a hub's start and skip records carry its name and description). The logger has slices for the common questions: errors(), fatals(), warnings(), timers(), returnValues() and recordsByKey($key); $envelope->hasErrors() and $envelope->hasFatalErrors() answer the most common ones, and $envelope->results() has successes(), failures(), fatalFailures() and skips(). Add your own lines with $envelope->logger()->info($key, $message). Every envelope has a runId(), a uuid that identifies the run.
Log levels
$envelope->logLevel(PipeLogger::LEVEL_EVERYTHING) is the default; LEVEL_NO_TIMERS drops the rule timers; LEVEL_PROBLEMS keeps only warnings, errors and fatals. Records below the level are never written. Results are recorded whatever the level.
Events
A hub can dispatch Laravel events at five points. Hub events are constructed with the envelope; per-rule events with the envelope and the rule's key. Declare the event classes:
<?php
use Keaton\PipeDream\PipeEnvelope;
class BotChecksStarted
{
public function __construct(public PipeEnvelope $click) {}
}
class BotChecksFinished
{
public function __construct(public PipeEnvelope $click) {}
}
class BotChecksSkipped
{
public function __construct(public PipeEnvelope $click) {}
}
class RuleStarting
{
public function __construct(public PipeEnvelope $click, public ?string $rule = null) {}
}
class RuleFinished
{
public function __construct(public PipeEnvelope $click, public ?string $rule = null) {}
}Then name them on the hub:
<?php
use Illuminate\Support\Facades\Event;
use Keaton\PipeDream\PipeHub;
$fired = [];
Event::listen(
[BotChecksStarted::class, BotChecksFinished::class, BotChecksSkipped::class, RuleStarting::class, RuleFinished::class],
function ($event) use (&$fired) {
$fired[] = class_basename($event).(isset($event->rule) ? '('.class_basename($event->rule).')' : '');
}
);
PipeHub::create()
->name('Bot checks')
->addPipes([DetectHostingProviderIp::class, MatchKnownBotUserAgents::class])
->broadcastEventBefore(BotChecksStarted::class) // before the first rule
->broadcastEventAfter(BotChecksFinished::class) // after the last rule, before then()
->broadcastEventOnSkip(BotChecksSkipped::class) // instead of both, when the hub is skipped
->broadcastEventBeforeEachPipe(RuleStarting::class) // just before each rule runs
->broadcastEventAfterEachPipe(RuleFinished::class) // as each rule hands off to the next
->sendIt((object) ['id' => 'clk_134', 'ip' => '203.0.113.9', 'userAgent' => 'Mozilla/5.0']);
implode(', ', $fired); // 'BotChecksStarted, RuleStarting(DetectHostingProviderIp), RuleFinished(DetectHostingProviderIp), RuleStarting(MatchKnownBotUserAgents), RuleFinished(MatchKnownBotUserAgents), BotChecksFinished'before and after bracket the rules, beforeEach/afterEach surround each rule in turn, and a skipped hub dispatches only the skip event. A rule that stops the hub never hands off, so its afterEach does not fire; rules skipped without running (by skipIf(), skipAll() or the rule's own skip flag) fire neither, while a rule that runs and returns $this->skip() fires both like any other. Every dispatch is written to the log under PipeHub:event.
Rule copies: shared and transient bindings
Each run resolves the rule from the container. An ordinary (transient) binding runs exactly as the container returned it. A rule bound as shared (singleton(), instance(), scoped(), or the container's #[Singleton]/#[Scoped] attributes on Laravel versions that have them) runs as a clone, so the shared object is never executed and the rule's own scalar settings never carry from one run to the next. PHP clones are shallow: an object held in a property of a shared rule is still shared, so give the rule a __clone() that copies it if each run needs its own. A shared rule that cannot be cloned (a private __clone(), or one that throws) is a configuration error: the flow throws a PipeHubException telling you to bind it non-shared, rather than silently skipping the rule. Keep anything that must outlive a run in an injected service. A hub given its own container (PipeHub::create($container)) runs its rules, callbacks and events in that container. An envelope you created yourself and passed in keeps the container it was created with for its own services, such as a persistence strategy bound in the container.
JSON-safe log values
Every value is made JSON-safe as it is recorded, so a value a rule returned can never make the log fail to encode and hide what the flow did. (Saving can still fail for ordinary reasons such as the database being down; those errors are not swallowed.) A rule may return anything, but NAN and INF are logged as the strings "NAN" / "INF", resources as "resource(stream)", closures as "Closure", backed enums as their value and unit (pure) enums as their name, dates as ISO 8601 strings, exceptions as their class and message, other objects as their public properties plus a __class key (or what their jsonSerialize(), toArray() or __toString() gives), cycles as "[recursion: Class]", nesting past 16 levels as "[max depth]", and invalid UTF-8 bytes are replaced. The raw value is still what $envelope->get(Rule::class) and $envelope->results() return.
Saving logs
Once a persistence strategy is set up, a manager saves the log once, when the flow ends, whether it completed, stopped early or threw; a hub run on its own saves when it finishes. ->persistLogs(false) turns that off, so you can call $envelope->persist() yourself. A run is saved with the strategy given to $envelope->persistWith(), else the one bound to PipeLoggerPersistStrategy in the container (bind DatabasePersistStrategy there once to use it for every flow); with neither, nothing is written.
The pipe_dream_logs row holds the run id, the flow's name, the log (JSON), has_errors / has_fatals, and the subject's type and id when the subject is a saved Eloquent model. It is written with a single upsert keyed on the run id. The query scopes PipeDreamLog::forRun($runId)->first() (one run) and PipeDreamLog::withProblems()->get() (runs with errors or fatals) find runs, and logger() rebuilds a run's records. new DatabasePersistStrategy(new PipeLoggerSerializeStrategy()) stores PHP serialize() output instead of JSON (rebuilt with allowed_classes => false, so a stored log can never instantiate objects); pass the same strategy to logger() to read it back. To store logs somewhere else, implement PipeLoggerPersistStrategy, which has one method, persist(PipeEnvelope $subject); $subject->logger()->output() gives you the log as JSON.
Introspecting a flow
Tools can read a flow back without reflection, which is how the demo renders its flow page from the same objects that process the clicks: PipeManager::getName(), getDescription(), getHubs(), getThen(), getOnError(), getOnFatal(), throwsFatals(), persistsLogs(); PipeHub::getName(), getDescription(), getPipes(), getSkipIf(), getThen(), getOnError(), getOnFatal(), getEvents(), stopsOnlyThisHubOnFatal(), treatsErrorsAsFatal(), isSkipped(), throwsFatals(), persistsLogs().
Compatibility
Tested on Laravel 9–13 (CI matrix) with PHP 8.1–8.4, each Laravel major on the PHP versions it supports: Laravel 9 on 8.1–8.2, 10 on 8.1–8.3, 11 and 12 on 8.2–8.4, 13 on 8.3–8.4.
Testing
composer install
composer testThe suite uses Orchestra Testbench, and its database tests run on in-memory SQLite. Every PHP block in this README is a complete file in docs/examples/, shown exactly as it is; tests/Feature/ReadmeExamplesTest.php runs each script and loads each class just as shown, checks every output shown and every // value comment.
Changelog and license
See CHANGELOG.md for what changed. MIT licensed; see LICENSE.