Skip to content

Event Listeners

Creating Listeners

<?php
declare(strict_types=1);

use Sputnik\Attribute\AsListener;
use Sputnik\Event\ContextSwitchedEvent;

#[AsListener(event: ContextSwitchedEvent::class, priority: -50)]
final class MyListener
{
    public function __invoke(ContextSwitchedEvent $event): void
    {
        if (!$event->hasChanged()) {
            return;
        }
        // React to context change
    }
}

AsListener Parameters

  • event (required) — fully qualified event class name
  • priority — higher runs first, default 0
  • environment'container', 'host', or null. When set, an EnvironmentAwareExecutor is injected. The constructor must accept ExecutorInterface $executor.

Environment-Aware Listeners

use Sputnik\Executor\ExecutorInterface;

#[AsListener(event: ContextSwitchedEvent::class, environment: 'container')]
final class ResetOnContextSwitch
{
    public function __construct(
        private readonly ExecutorInterface $executor,
    ) {}

    public function __invoke(ContextSwitchedEvent $event): void
    {
        $this->executor->execute(['composer', 'install', '--no-interaction']);
        // Automatically wrapped with docker exec on host, runs directly in container
    }
}

The command is echoed and its output streams to the console, exactly as it does inside a task, and secret values are masked on the way out.

Writing Output

Inject OutputChannel to write from a listener. It is the same destination tasks write to, so masking applies -- echo and print bypass it and can leak a secret:

use Sputnik\Console\OutputChannel;

#[AsListener(event: ContextSwitchedEvent::class)]
final class AnnounceSwitch
{
    public function __construct(
        private readonly OutputChannel $output,
    ) {}

    public function __invoke(ContextSwitchedEvent $event): void
    {
        $this->output->writeln('Switched to ' . $event->newContext);
        $this->output->comment('Remember to rebuild assets');
    }
}

A listener that runs before a command has produced output -- during ConfigLoadedEvent, for example -- writes nowhere rather than failing.

Available Events

ConfigLoadedEvent

Dispatched after configuration is loaded.

Property Type Description
config Configuration The loaded configuration object

BeforeTaskEvent

Dispatched before a task runs.

Property/Method Description
task The task about to run
arguments Task arguments
options Task options
cancel(reason) Cancel the task with a reason string
isCancelled() Returns true if the task has been cancelled

AfterTaskEvent

Dispatched after a task completes successfully.

Property/Method Description
task The task that ran
result The task result
duration Execution duration in seconds
isSuccessful() Returns true if the task completed without error

TaskFailedEvent

Dispatched when a task throws an exception.

Property Description
task The task that failed
exception The thrown exception

ContextSwitchedEvent

Dispatched after a context switch.

Property/Method Description
previousContext The context before the switch
newContext The context after the switch
hasChanged() Returns true if the context actually changed

TemplateRenderedEvent

Dispatched after a template is rendered.

Property Description
template The template that was rendered
outputPath Path the output was written to
written Whether the file was actually written
skipReason Reason the file was skipped, if applicable

Built-in Listeners

Listener Priority Description
SwitchContextOnServices 100 Switches VariableResolver and TemplateEngine to the new context
RegenerateTemplatesOnContextSwitch 0 Re-renders templates after a context switch

Discovery

Listeners are discovered from the same directories as tasks. A listener must:

  • Have the #[AsListener] attribute on the class
  • Implement __invoke() with the event as parameter
  • Be placed in a directory listed in tasks.directories

The class name does not matter -- only the attribute determines discovery.

Reporting a failure

A listener returns nothing, so throwing is how it reports that its work did not happen. A command that fails is not a failure by itself -- as in a task, the result is yours to check:

public function __invoke(ContextSwitchedEvent $event): void
{
    $result = $this->executor->execute(['composer', 'install', '--no-interaction']);

    if (!$result->isSuccessful()) {
        throw new \RuntimeException('composer install failed after the context switch');
    }
}

Without the check, the command's output is visible but the run still reports success -- and for ContextSwitchedEvent that used to mean the new context was remembered while the work belonging to it had not been done. An exception prevents that: the switch is not persisted, and the message names the file and line it came from.

Priority Order

Higher priority runs first.

Tip

Use negative priorities to run after built-in listeners.

Priority Listener
100 SwitchContextOnServices
0 RegenerateTemplatesOnContextSwitch
negative custom listeners that should run after built-ins