Controlling the flow
When a step goes wrong, two things decide what happens next: what the rule says happened, and how far its hub lets that reach. Ask what you want to happen, and the answer is one of three:
| You want to | In the rule | On the hub |
|---|---|---|
| note it and keep going: the next pipe runs | $this->error('why') |
nothing to set |
| end this hub and move on to the next one | $this->fatal('why') |
->fatalsStopThisHubOnly() |
| stop everything | $this->fatal('why') |
nothing to set: this is the default |
error()records a failure (PipeRule::FAIL) with its message, and the next pipe runs. An unexpected exception in a rule (anyThrowable, PHP errors such asTypeErrorincluded) is handled the same way.fatal()records a fatal (PipeRule::FATAL_FAIL), and by default nothing after it runs: not the rest of its hub, not any later hub, and neither the hub'sthen()nor the manager'sthen().fatalsStopThisHubOnly()contains a hub's fatals: the rest of that hub and itsthen()do not run, the manager moves on to the next hub, and the manager'sthen()still runs. It only matters inside a manager; a hub run on its own is the whole flow.errorsAreFatal()on a hub makes everyerror()and every unexpected exception in its pipes a fatal, and thenfatalsStopThisHubOnly()decides how far it reaches. A rule can do the same for itself withprotected $errorsAreFatal = true;.
RejectEmptyTitle stops on an empty title, and CheckTitleLength (from Rules) calls error() on a title over twelve words. Here they are in a three-hub flow, run five ways. Each line lists the rules that ran, in order, with what they recorded; a rule the stop reached never runs, so it has no result at all:
<?php
use Keaton\PipeDream\PipeRule;
class RejectEmptyTitle extends PipeRule
{
public static $name = 'Reject empty titles';
public static $description = 'A post needs a title';
public function rejectEmptyTitle($post)
{
if (trim($post->title) === '') {
$this->fatal('The post has no title');
}
return self::SUCCESS;
}
}
<?php
use Keaton\PipeDream\PipeHub;
use Keaton\PipeDream\PipeManager;
use Keaton\PipeDream\PipeRule;
$publish = function (string $title, ?Closure $check = null, ?Closure $edit = null) {
$check ??= fn (PipeHub $hub) => $hub;
$edit ??= fn (PipeHub $hub) => $hub;
$managerThen = 'did not run';
$envelope = PipeManager::create([
$check(PipeHub::create()->name('Check')->addPipe(RejectEmptyTitle::class)),
$edit(PipeHub::create()->name('Edit')->addPipes([TidyTitle::class, CountWords::class, CheckTitleLength::class])),
PipeHub::create()->name('Publish')->addPipe(SlugifyTitle::class),
])
->subject((object) ['title' => $title])
->then(function () use (&$managerThen) {
$managerThen = 'ran';
})
->sendIt();
$outcome = [PipeRule::SUCCESS => 'ok', PipeRule::FAIL => 'error', PipeRule::FATAL_FAIL => 'FATAL', PipeRule::SKIP => 'skip'];
$ran = $envelope->results()->map(fn ($result) => $result->rule().' '.$outcome[$result->result()])->implode(', ');
return "{$ran}. Manager then(): {$managerThen}";
};
$long = 'A title that keeps on going for many more words than any feed will show';
echo "1. error() in Edit:\n ", $publish($long), PHP_EOL;
echo "2. fatal() in Check:\n ", $publish(''), PHP_EOL;
echo "3. fatal() in Check, with fatalsStopThisHubOnly():\n ", $publish('', fn ($hub) => $hub->fatalsStopThisHubOnly()), PHP_EOL;
echo "4. error() in Edit, with errorsAreFatal():\n ", $publish($long, null, fn ($hub) => $hub->errorsAreFatal()), PHP_EOL;
echo "5. error() in Edit, with errorsAreFatal() and fatalsStopThisHubOnly():\n ", $publish($long, null, fn ($hub) => $hub->errorsAreFatal()->fatalsStopThisHubOnly()), PHP_EOL;
1. error() in Edit:
RejectEmptyTitle ok, TidyTitle ok, CountWords ok, CheckTitleLength error, SlugifyTitle ok. Manager then(): ran
2. fatal() in Check:
RejectEmptyTitle FATAL. Manager then(): did not run
3. fatal() in Check, with fatalsStopThisHubOnly():
RejectEmptyTitle FATAL, TidyTitle ok, CountWords ok, CheckTitleLength skip, SlugifyTitle ok. Manager then(): ran
4. error() in Edit, with errorsAreFatal():
RejectEmptyTitle ok, TidyTitle ok, CountWords ok, CheckTitleLength FATAL. Manager then(): did not run
5. error() in Edit, with errorsAreFatal() and fatalsStopThisHubOnly():
RejectEmptyTitle ok, TidyTitle ok, CountWords ok, CheckTitleLength FATAL, SlugifyTitle ok. Manager then(): ranonError() and onFatal()
Hubs and managers both take two hooks, each called with the envelope and the exception:
onError(fn ($subject, $exception) => …)runs for everyerror()and unexpected exception a pipe records, and then the next pipe runs. A hub's hook hears about its own pipes; a manager's hears about every hub's, after the hub's own hook.onFatal(fn ($subject, $exception) => …)runs when a fatal stops things, with thePipeRuleFatal. A hub's hook runs when a fatal stops that hub, whether it is contained or not; a manager's runs when a fatal stops the whole flow (not for one contained to its hub), after the hub's. Both run beforesendIt()returns or throws, so what they log is saved with the rest. An error thaterrorsAreFatal()turns into a fatal goes toonFatal(), notonError(), and the fatal'sgetPrevious()is the original error.
What a hook throws decides what happens next. A redirect or view is handed off and leaves sendIt() (see Redirects and views below), and a PipeHubException leaves sendIt() as a configuration error, from either hook; in both cases that exception, not a fatal, is what reaches your code. A PipeRuleFatal thrown by onError() escalates the error: it is recorded and stops things like any fatal. Any other exception from onError() means the hub cannot carry on, so it stops as for a fatal wrapping that exception. Any other exception from onFatal() is recorded as a fatal and the stop carries on exactly as it would have.
then() callbacks are handled like pipes: an exception thrown in one is an error (recorded, sent to onError(), and the flow carries on), or a fatal under the hub's errorsAreFatal(), and a PipeRuleFatal thrown there stops things like any fatal. If a skipIf() callback or an event listener throws, the hub cannot carry on either, so it stops as for a fatal, with the exception as the fatal's getPrevious().
Redirects and views
A rule can also hand control back to your controller: throwing PipeRuleRedirect (PipeRuleRedirect::toUrl($url) or ::toRoute($name)) or PipeRuleLoadView (PipeRuleLoadView::load($view, $data)) ends the flow and leaves sendIt() as that exception. One thrown from then(), onError() or onFatal() does the same.
<?php
use Keaton\PipeDream\Exceptions\PipeRuleRedirect;
use Keaton\PipeDream\PipeRule;
class SendDraftsToPreview extends PipeRule
{
public static $name = 'Send drafts to the preview';
public static $description = 'Drafts are previewed, not published';
public function sendDraftsToPreview($post)
{
if ($post->status === 'draft') {
PipeRuleRedirect::toUrl("/preview/{$post->id}");
}
return self::SUCCESS;
}
}Quiet or thrown
By default a stop is quiet: sendIt() returns the envelope, and hasFatalErrors() tells you a fatal stopped it. So a controller wraps the run in a try/catch for the redirects and views, and checks the envelope for a fatal:
<?php
use Keaton\PipeDream\Exceptions\PipeRuleLoadView;
use Keaton\PipeDream\Exceptions\PipeRuleRedirect;
use Keaton\PipeDream\PipeHub;
use Keaton\PipeDream\PipeManager;
class PublishPost
{
public function __invoke(object $post)
{
try {
$envelope = PipeManager::create([
PipeHub::create()
->name('Check')
->addPipes([RejectEmptyTitle::class, SendDraftsToPreview::class]),
PipeHub::create()
->name('Publish')
->addPipes([TidyTitle::class, SlugifyTitle::class]),
])
->name('Publish a post')
->subject($post)
->sendIt();
} catch (PipeRuleRedirect $e) {
return redirect($e->redirectTo);
} catch (PipeRuleLoadView $e) {
return view($e->view, $e->data);
}
// By default a fatal is not thrown: it is recorded on the envelope.
if ($envelope->hasFatalErrors()) {
return response()->json(['error' => 'This post cannot be published'], 422);
}
return redirect("/posts/{$post->slug}");
}
}
<?php
$publish = app(PublishPost::class);
$posts = [
(object) ['id' => 1, 'title' => ' hello, PIPE dream ', 'status' => 'ready'],
(object) ['id' => 2, 'title' => ' ', 'status' => 'ready'],
(object) ['id' => 3, 'title' => 'Work in progress', 'status' => 'draft'],
];
foreach ($posts as $post) {
$response = $publish($post);
echo $post->id, ': ', $response->getStatusCode(), ' ', $response->headers->get('Location') ?? $response->getContent(), PHP_EOL;
}
1: 302 http://localhost/posts/hello-pipe-dream
2: 422 {"error":"This post cannot be published"}
3: 302 http://localhost/preview/3To get the fatal as an exception instead, call throwFatals() on the manager (or on a hub you run on its own). When a fatal stops the flow, sendIt() throws that same PipeRuleFatal (unless an onFatal() hook hands off a redirect or view, or throws a configuration error, which leaves instead), but only after everything that happens without it: the fatal is logged, onFatal() runs, then() does not run and the log is persisted just as it would be without it (see Keeping the log below: with persistLogs(false), or no strategy set, nothing is saved, and an exception from saving takes the fatal's place). $e->subject() is the envelope. A fatal contained by fatalsStopThisHubOnly() is never thrown, because the flow carried on:
<?php
use Keaton\PipeDream\Exceptions\PipeRuleFatal;
use Keaton\PipeDream\PipeHub;
use Keaton\PipeDream\PipeManager;
$flow = fn (object $post, bool $checkStopsOnlyItself = false) => PipeManager::create([
PipeHub::create()
->name('Check')
->addPipe(RejectEmptyTitle::class)
->fatalsStopThisHubOnly($checkStopsOnlyItself),
PipeHub::create()
->name('Publish')
->addPipes([TidyTitle::class, SlugifyTitle::class]),
])
->name('Publish a post')
->subject($post)
->onFatal(function ($post, $fatal) {
echo ' onFatal(): ', $fatal->getMessage(), PHP_EOL;
});
echo 'Quiet (the default):', PHP_EOL;
$envelope = $flow((object) ['title' => ''])->sendIt();
echo ' returned, hasFatalErrors() is ', var_export($envelope->hasFatalErrors(), true), PHP_EOL;
echo 'With throwFatals():', PHP_EOL;
try {
$flow((object) ['title' => ''])->throwFatals()->sendIt();
} catch (PipeRuleFatal $e) {
// The exception carries the envelope, its log included.
echo ' caught "', $e->getMessage(), '", hasFatalErrors() is ', var_export($e->subject()->hasFatalErrors(), true), PHP_EOL;
}
echo 'With throwFatals(), the fatal contained to Check:', PHP_EOL;
$post = (object) ['title' => ''];
$envelope = $flow($post, true)->throwFatals()->sendIt();
echo ' returned, Publish ran: ', var_export(isset($post->slug), true), PHP_EOL;
Quiet (the default):
onFatal(): The post has no title
returned, hasFatalErrors() is true
With throwFatals():
onFatal(): The post has no title
caught "The post has no title", hasFatalErrors() is true
With throwFatals(), the fatal contained to Check:
returned, Publish ran: trueThe last run prints no onFatal() line: the manager's hook is for a fatal that stops the flow, and this one was contained to its hub. Inside a manager, a hub's own throwFatals() is ignored: set it on the manager. Redirects, views and configuration errors leave sendIt() the same way with or without it.