Kevin Eaton

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 (any Throwable, PHP errors such as TypeError included) 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's then() nor the manager's then().
  • fatalsStopThisHubOnly() contains a hub's fatals: the rest of that hub and its then() do not run, the manager moves on to the next hub, and the manager's then() still runs. It only matters inside a manager; a hub run on its own is the whole flow.
  • errorsAreFatal() on a hub makes every error() and every unexpected exception in its pipes a fatal, and then fatalsStopThisHubOnly() decides how far it reaches. A rule can do the same for itself with protected $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(): ran

onError() and onFatal()

Hubs and managers both take two hooks, each called with the envelope and the exception:

  • onError(fn ($subject, $exception) => …) runs for every error() 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 the PipeRuleFatal. 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 before sendIt() returns or throws, so what they log is saved with the rest. An error that errorsAreFatal() turns into a fatal goes to onFatal(), not onError(), and the fatal's getPrevious() 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/3

To 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: true

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