diff --git a/AGENTS.md b/AGENTS.md
index b07274f..7bc74ab 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -4,11 +4,12 @@
Foundation is a StellarWP Composer monorepo for reusable PHP packages intended for libraries and WordPress plugin ecosystems.
-Initial packages:
+Split packages:
- `stellarwp/foundation-container`
- `stellarwp/foundation-log`
- `stellarwp/foundation-pipeline`
+- `stellarwp/foundation-shutdown`
- `stellarwp/foundation-wpcli`
- `stellarwp/foundation-cli`
diff --git a/README.md b/README.md
index 3a83a88..3c67a7b 100644
--- a/README.md
+++ b/README.md
@@ -9,6 +9,7 @@ Foundation is a StellarWP Composer monorepo for reusable PHP packages intended f
- [stellarwp/foundation-container](https://github.com/stellarwp/foundation-container)
- [stellarwp/foundation-pipeline](https://github.com/stellarwp/foundation-pipeline)
- [stellarwp/foundation-log](https://github.com/stellarwp/foundation-log)
+- [stellarwp/foundation-shutdown](https://github.com/stellarwp/foundation-shutdown)
- [stellarwp/foundation-wpcli](https://github.com/stellarwp/foundation-wpcli)
- [stellarwp/foundation-cli](https://github.com/stellarwp/foundation-cli)
diff --git a/composer.json b/composer.json
index 7595932..f123dbd 100644
--- a/composer.json
+++ b/composer.json
@@ -37,6 +37,7 @@
"stellarwp/foundation-container": "self.version",
"stellarwp/foundation-log": "self.version",
"stellarwp/foundation-pipeline": "self.version",
+ "stellarwp/foundation-shutdown": "self.version",
"stellarwp/foundation-wpcli": "self.version"
},
"minimum-stability": "dev",
@@ -47,6 +48,7 @@
"StellarWP\\Foundation\\Container\\": "src/Container/",
"StellarWP\\Foundation\\Log\\": "src/Log/",
"StellarWP\\Foundation\\Pipeline\\": "src/Pipeline/",
+ "StellarWP\\Foundation\\Shutdown\\": "src/Shutdown/",
"StellarWP\\Foundation\\WPCli\\": "src/WPCli/"
},
"exclude-from-classmap": [
diff --git a/src/Shutdown/.gitattributes b/src/Shutdown/.gitattributes
new file mode 100644
index 0000000..e82014a
--- /dev/null
+++ b/src/Shutdown/.gitattributes
@@ -0,0 +1,7 @@
+# Path-based git attributes
+# https://www.kernel.org/pub/software/scm/git/docs/gitattributes.html
+
+# Ignore paths when git creates an archive of this package
+.gitattributes export-ignore
+.gitignore export-ignore
+.github export-ignore
diff --git a/src/Shutdown/.github/workflows/close-pull-request.yml b/src/Shutdown/.github/workflows/close-pull-request.yml
new file mode 100644
index 0000000..6bfbabe
--- /dev/null
+++ b/src/Shutdown/.github/workflows/close-pull-request.yml
@@ -0,0 +1,13 @@
+name: Close Pull Request
+
+on:
+ pull_request_target:
+ types: [opened]
+
+jobs:
+ run:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: superbrothers/close-pull-request@v3
+ with:
+ comment: "This is a read-only repository. Please submit your PR on the https://github.com/stellarwp/foundation repository.
Thanks!"
diff --git a/src/Shutdown/.gitignore b/src/Shutdown/.gitignore
new file mode 100644
index 0000000..d1502b0
--- /dev/null
+++ b/src/Shutdown/.gitignore
@@ -0,0 +1,2 @@
+vendor/
+composer.lock
diff --git a/src/Shutdown/Contracts/ShutdownRunner.php b/src/Shutdown/Contracts/ShutdownRunner.php
new file mode 100644
index 0000000..d222d02
--- /dev/null
+++ b/src/Shutdown/Contracts/ShutdownRunner.php
@@ -0,0 +1,10 @@
+ [!WARNING]
+> **This is a read-only repository!** For pull requests or issues, see [stellarwp/foundation](https://github.com/stellarwp/foundation).
+
+Run application termination work once, in a predictable order, without allowing
+one failed task to prevent the remaining tasks from running.
+
+## Installation
+
+```shell
+composer require stellarwp/foundation-shutdown
+```
+
+## Register the provider
+
+Register `ShutdownProvider` through your application's normal Foundation provider
+list:
+
+```php
+use StellarWP\Foundation\Shutdown\ShutdownProvider;
+
+private array $providers = [
+ ShutdownProvider::class,
+];
+```
+
+The provider has no custom constructor and uses the application's existing
+Foundation container and configuration. Package installation alone has no side
+effects; consumers may omit this provider and construct the public runner directly
+or supply their own provider.
+
+## Create and contribute tasks
+
+Termination work implements the small `Terminable` contract:
+
+```php
+use StellarWP\Foundation\Shutdown\Contracts\Terminable;
+
+final class FlushTelemetry implements Terminable
+{
+ public function terminate(): void {
+ // Flush bounded application telemetry.
+ }
+}
+
+final class CloseRequestLog implements Terminable
+{
+ public function terminate(): void {
+ // Close the request log after the response is sent.
+ }
+}
+```
+
+Contribute an application's termination work from one provider. Resolve the
+concrete tasks lazily so all providers can finish registering before termination
+services are constructed. Contributions must be registered before the runner is
+resolved:
+
+```php
+use lucatume\DI52\Container;
+use StellarWP\Foundation\Container\Contracts\Provider;
+use StellarWP\Foundation\Shutdown\ShutdownProvider as FoundationShutdownProvider;
+use StellarWP\Foundation\Shutdown\ShutdownTask;
+
+final class ApplicationShutdownProvider extends Provider
+{
+ public function register(): void {
+ $this->container->singleton(CloseRequestLog::class);
+ $this->container->singleton(FlushTelemetry::class);
+
+ $this->container->mergeArrayVar(
+ FoundationShutdownProvider::TASKS,
+ static fn (Container $container): array => [
+ new ShutdownTask($container->get(CloseRequestLog::class), 10),
+ new ShutdownTask($container->get(FlushTelemetry::class), 100),
+ ]
+ );
+ }
+}
+```
+
+Register both providers through the application's provider list:
+
+```php
+private array $providers = [
+ FoundationShutdownProvider::class,
+ ApplicationShutdownProvider::class,
+];
+```
+
+Lower priority values run first. Tasks with the same priority retain their
+registration order.
+
+## WordPress shutdown
+
+`ShutdownProvider` binds the `ShutdownRunner` contract to the ordered task runner,
+decorated by `ResponseFinishingRunner`, and attaches it to WordPress's `shutdown`
+action at the latest priority. When supported, it finishes the response with
+`fastcgi_finish_request()` or `litespeed_finish_request()` before running the
+contributed tasks. The PHP worker remains occupied until those tasks finish, so
+long-running work still belongs in a proper background queue.
+
+The runner is resolved lazily when the action fires, so features may contribute
+tasks after the provider is registered.
+
+With the default provider registered, applications may also invoke the configured
+runner chain directly:
+
+```php
+use StellarWP\Foundation\Shutdown\Contracts\ShutdownRunner;
+
+$container->get(ShutdownRunner::class)->terminate();
+```
+
+Applications that omit the default provider must bind the `ShutdownRunner` contract
+in their own provider or construct the concrete
+`StellarWP\Foundation\Shutdown\ShutdownRunner` with their desired tasks.
+
+Each runner instance executes only once, including when termination is invoked
+recursively. A `Throwable` from one task is isolated so later tasks still run.
+
+## Logging
+
+`ShutdownRunner` accepts an optional PSR-3 logger. When the application binds a
+`Psr\Log\LoggerInterface`—including through `foundation-log`—the container injects
+it automatically. Applications without a logger require no additional setup.
+
+The runner logs the task count and each task at `debug` level. Task failures are
+logged at `error` level with the task class, priority, and actual exception so
+compatible loggers retain its message and stack trace. Logger failures are isolated
+so diagnostics cannot interrupt termination work.
+
+Output-buffer management, hard task timeouts, and asynchronous execution beyond
+the default WordPress shutdown action belong to the consuming application or a
+dedicated framework integration.
diff --git a/src/Shutdown/ResponseFinishingRunner.php b/src/Shutdown/ResponseFinishingRunner.php
new file mode 100644
index 0000000..49a6b39
--- /dev/null
+++ b/src/Shutdown/ResponseFinishingRunner.php
@@ -0,0 +1,43 @@
+terminated) {
+ return;
+ }
+
+ $this->terminated = true;
+
+ foreach (['fastcgi_finish_request', 'litespeed_finish_request'] as $finishRequest) {
+ if (! function_exists($finishRequest)) {
+ continue;
+ }
+
+ try {
+ if ($finishRequest()) {
+ break;
+ }
+ } catch (Throwable) {
+ // Response finishing is best-effort and must not block termination work.
+ }
+ }
+
+ $this->runner->terminate();
+ }
+}
diff --git a/src/Shutdown/ShutdownProvider.php b/src/Shutdown/ShutdownProvider.php
new file mode 100644
index 0000000..95ecdab
--- /dev/null
+++ b/src/Shutdown/ShutdownProvider.php
@@ -0,0 +1,42 @@
+container->has(self::REGISTERED)) {
+ return;
+ }
+
+ $this->container->singleton(self::REGISTERED, true);
+
+ $this->container->when(ShutdownRunner::class)
+ ->needs('$tasks')
+ ->give(static fn (Container $container): array => $container->getVar(self::TASKS, []));
+
+ $this->container->singletonDecorators(ShutdownRunnerContract::class, [
+ ResponseFinishingRunner::class,
+ ShutdownRunner::class,
+ ]);
+
+ add_action(
+ 'shutdown',
+ $this->container->callback(ShutdownRunnerContract::class, 'terminate'),
+ PHP_INT_MAX
+ );
+ }
+}
diff --git a/src/Shutdown/ShutdownRunner.php b/src/Shutdown/ShutdownRunner.php
new file mode 100644
index 0000000..59ef64d
--- /dev/null
+++ b/src/Shutdown/ShutdownRunner.php
@@ -0,0 +1,106 @@
+ */
+ private array $tasks;
+
+ private bool $terminated = false;
+
+ /**
+ * @param array $tasks
+ */
+ public function __construct(
+ array $tasks = [],
+ private readonly ?LoggerInterface $logger = null
+ ) {
+ foreach ($tasks as $task) {
+ if (! $task instanceof ShutdownTask) {
+ throw new InvalidArgumentException('Shutdown tasks must be instances of ShutdownTask.');
+ }
+ }
+
+ $this->tasks = array_values($tasks);
+ }
+
+ public function terminate(): void {
+ if ($this->terminated) {
+ return;
+ }
+
+ $this->terminated = true;
+
+ $tasks = $this->orderedTasks();
+
+ $this->log(LogLevel::DEBUG, 'Running shutdown tasks.', [
+ 'task_count' => count($tasks),
+ ]);
+
+ foreach ($tasks as $task) {
+ $context = [
+ 'task' => $task->terminable::class,
+ 'priority' => $task->priority,
+ ];
+
+ $this->log(LogLevel::DEBUG, 'Running shutdown task.', $context);
+
+ try {
+ $task->terminable->terminate();
+ } catch (Throwable $exception) {
+ $this->log(LogLevel::ERROR, 'Shutdown task failed.', $context + [
+ 'exception' => $exception,
+ ]);
+ }
+ }
+ }
+
+ /**
+ * Logging must not interrupt application termination.
+ *
+ * @param array $context
+ */
+ private function log(string $level, string $message, array $context = []): void {
+ try {
+ $this->logger?->log($level, $message, $context);
+ } catch (Throwable) {
+ // The remaining termination work is more important than diagnostics.
+ }
+ }
+
+ /**
+ * @return list
+ */
+ private function orderedTasks(): array {
+ /** @var list $indexedTasks */
+ $indexedTasks = [];
+
+ foreach ($this->tasks as $index => $task) {
+ $indexedTasks[] = [
+ 'index' => $index,
+ 'task' => $task,
+ ];
+ }
+
+ usort(
+ $indexedTasks,
+ static fn (array $left, array $right): int => ($left['task']->priority <=> $right['task']->priority)
+ ?: ($left['index'] <=> $right['index'])
+ );
+
+ return array_map(
+ static fn (array $entry): ShutdownTask => $entry['task'],
+ $indexedTasks
+ );
+ }
+}
diff --git a/src/Shutdown/ShutdownTask.php b/src/Shutdown/ShutdownTask.php
new file mode 100644
index 0000000..39ba450
--- /dev/null
+++ b/src/Shutdown/ShutdownTask.php
@@ -0,0 +1,17 @@
+=8.3",
+ "psr/log": ">=1.0",
+ "stellarwp/foundation-container": "^2.0"
+ },
+ "autoload": {
+ "psr-4": {
+ "StellarWP\\Foundation\\Shutdown\\": ""
+ }
+ },
+ "extra": {
+ "branch-alias": {
+ "dev-main": "2.0.x-dev"
+ }
+ }
+}
diff --git a/tests/Support/Fixtures/Shutdown/CallbackTerminable.php b/tests/Support/Fixtures/Shutdown/CallbackTerminable.php
new file mode 100644
index 0000000..5ea7b66
--- /dev/null
+++ b/tests/Support/Fixtures/Shutdown/CallbackTerminable.php
@@ -0,0 +1,18 @@
+callback)();
+ }
+}
diff --git a/tests/Support/Fixtures/Shutdown/finish-request-functions.php b/tests/Support/Fixtures/Shutdown/finish-request-functions.php
new file mode 100644
index 0000000..9ef3f06
--- /dev/null
+++ b/tests/Support/Fixtures/Shutdown/finish-request-functions.php
@@ -0,0 +1,21 @@
+markTestSkipped('Native response-finishing functions cannot be replaced by test fixtures.');
+ }
+ }
+
+ #[RunInSeparateProcess]
+ public function test_it_finishes_the_response_and_runs_shutdown_tasks_once(): void {
+ require dirname(__DIR__, 2) . '/Support/Fixtures/Shutdown/litespeed-finish-request.php';
+
+ $GLOBALS['foundation_shutdown_calls'] = [];
+
+ $runner = $this->runner();
+
+ $runner->terminate();
+ $runner->terminate();
+
+ $this->assertSame(['litespeed', 'task'], $GLOBALS['foundation_shutdown_calls']);
+
+ unset($GLOBALS['foundation_shutdown_calls']);
+ }
+
+ #[RunInSeparateProcess]
+ public function test_it_prefers_fastcgi_response_finishing(): void {
+ require dirname(__DIR__, 2) . '/Support/Fixtures/Shutdown/finish-request-functions.php';
+
+ $GLOBALS['foundation_shutdown_calls'] = [];
+
+ $this->runner()->terminate();
+
+ $this->assertSame(['fastcgi', 'task'], $GLOBALS['foundation_shutdown_calls']);
+
+ unset($GLOBALS['foundation_shutdown_calls']);
+ }
+
+ #[RunInSeparateProcess]
+ public function test_a_response_finishing_failure_does_not_prevent_shutdown_tasks(): void {
+ require dirname(__DIR__, 2) . '/Support/Fixtures/Shutdown/finish-request-functions.php';
+
+ $GLOBALS['foundation_shutdown_calls'] = [];
+ $GLOBALS['foundation_shutdown_fastcgi_failure'] = true;
+
+ $this->runner()->terminate();
+
+ $this->assertSame(['fastcgi', 'litespeed', 'task'], $GLOBALS['foundation_shutdown_calls']);
+
+ unset(
+ $GLOBALS['foundation_shutdown_calls'],
+ $GLOBALS['foundation_shutdown_fastcgi_failure']
+ );
+ }
+
+ #[RunInSeparateProcess]
+ public function test_it_falls_back_when_fastcgi_does_not_finish_the_response(): void {
+ require dirname(__DIR__, 2) . '/Support/Fixtures/Shutdown/finish-request-functions.php';
+
+ $GLOBALS['foundation_shutdown_calls'] = [];
+ $GLOBALS['foundation_shutdown_fastcgi_false'] = true;
+
+ $this->runner()->terminate();
+
+ $this->assertSame(['fastcgi', 'litespeed', 'task'], $GLOBALS['foundation_shutdown_calls']);
+
+ unset(
+ $GLOBALS['foundation_shutdown_calls'],
+ $GLOBALS['foundation_shutdown_fastcgi_false']
+ );
+ }
+
+ private function runner(): ResponseFinishingRunner {
+ return new ResponseFinishingRunner(new ShutdownRunner([
+ new ShutdownTask(new CallbackTerminable(static function (): void {
+ $GLOBALS['foundation_shutdown_calls'][] = 'task';
+ })),
+ ]));
+ }
+}
diff --git a/tests/Unit/Shutdown/ShutdownRunnerTest.php b/tests/Unit/Shutdown/ShutdownRunnerTest.php
new file mode 100644
index 0000000..fd77909
--- /dev/null
+++ b/tests/Unit/Shutdown/ShutdownRunnerTest.php
@@ -0,0 +1,154 @@
+recordingTask($calls, 'last', 100),
+ $this->recordingTask($calls, 'second', 10),
+ $this->recordingTask($calls, 'third', 10),
+ $this->recordingTask($calls, 'first', 0),
+ ]);
+
+ $runner->terminate();
+
+ $this->assertSame(['first', 'second', 'third', 'last'], $calls);
+ }
+
+ public function test_it_runs_each_task_only_once(): void {
+ $calls = [];
+ $runner = new ShutdownRunner([$this->recordingTask($calls, 'task')]);
+
+ $runner->terminate();
+ $runner->terminate();
+
+ $this->assertSame(['task'], $calls);
+ }
+
+ public function test_it_is_safe_to_invoke_recursively(): void {
+ $calls = [];
+ $runner = new ShutdownRunner();
+
+ $recursive = new CallbackTerminable(static function () use (&$calls, &$runner): void {
+ $calls[] = 'recursive';
+ $runner->terminate();
+ });
+
+ $runner = new ShutdownRunner([
+ new ShutdownTask($recursive),
+ $this->recordingTask($calls, 'next'),
+ ]);
+
+ $runner->terminate();
+
+ $this->assertSame(['recursive', 'next'], $calls);
+ }
+
+ public function test_a_failed_task_does_not_prevent_later_tasks(): void {
+ $calls = [];
+
+ $failing = new CallbackTerminable(static function () use (&$calls): void {
+ $calls[] = 'failed';
+
+ throw new Error('Expected test failure.');
+ });
+
+ $runner = new ShutdownRunner([
+ new ShutdownTask($failing),
+ $this->recordingTask($calls, 'completed'),
+ ]);
+
+ $runner->terminate();
+
+ $this->assertSame(['failed', 'completed'], $calls);
+ }
+
+ public function test_it_logs_task_execution_and_failures_when_a_logger_is_available(): void {
+ $handler = new TestHandler();
+ $logger = new Logger('shutdown', [$handler]);
+ $failure = new Error('Expected test failure.', 42);
+ $failing = new CallbackTerminable(static function () use ($failure): void {
+ throw $failure;
+ });
+
+ $runner = new ShutdownRunner([
+ new ShutdownTask($failing, 10),
+ ], $logger);
+
+ $runner->terminate();
+
+ $records = $handler->getRecords();
+
+ $this->assertCount(3, $records);
+ $this->assertSame('Running shutdown tasks.', $records[0]['message']);
+ $this->assertSame(['task_count' => 1], $records[0]['context']);
+ $this->assertSame('Running shutdown task.', $records[1]['message']);
+ $this->assertSame([
+ 'task' => CallbackTerminable::class,
+ 'priority' => 10,
+ ], $records[1]['context']);
+ $this->assertSame('Shutdown task failed.', $records[2]['message']);
+ $this->assertSame([
+ 'task' => CallbackTerminable::class,
+ 'priority' => 10,
+ 'exception' => $failure,
+ ], $records[2]['context']);
+ }
+
+ public function test_a_failed_logger_does_not_prevent_termination_work(): void {
+ $calls = [];
+ $logger = $this->createMock(LoggerInterface::class);
+
+ $logger->method('log')->willThrowException(new Error('Expected logger failure.'));
+
+ $runner = new ShutdownRunner([
+ $this->recordingTask($calls, 'completed'),
+ ], $logger);
+
+ $runner->terminate();
+
+ $this->assertSame(['completed'], $calls);
+ }
+
+ public function test_it_accepts_an_empty_task_list(): void {
+ $runner = new ShutdownRunner();
+
+ $runner->terminate();
+ $runner->terminate();
+
+ $this->addToAssertionCount(1);
+ }
+
+ public function test_it_rejects_invalid_task_contributions(): void {
+ $this->expectException(InvalidArgumentException::class);
+ $this->expectExceptionMessage('Shutdown tasks must be instances of ShutdownTask.');
+
+ $runner = new ShutdownRunner(['invalid']);
+ }
+
+ /**
+ * @param list $calls
+ */
+ private function recordingTask(array &$calls, string $name, int $priority = 0): ShutdownTask {
+ return new ShutdownTask(
+ new CallbackTerminable(static function () use (&$calls, $name): void {
+ $calls[] = $name;
+ }),
+ $priority
+ );
+ }
+}
diff --git a/tests/wpunit/Shutdown/ShutdownProviderTest.php b/tests/wpunit/Shutdown/ShutdownProviderTest.php
new file mode 100644
index 0000000..53690b3
--- /dev/null
+++ b/tests/wpunit/Shutdown/ShutdownProviderTest.php
@@ -0,0 +1,101 @@
+container = new ContainerAdapter(new DI52Container());
+ $this->container->bind(Container::class, $this->container);
+ $this->container->singleton(Dot::class, new Dot());
+ }
+
+ protected function tearDown(): void {
+ if ($this->container->has(ShutdownRunnerContract::class)) {
+ remove_action(
+ 'shutdown',
+ $this->container->callback(ShutdownRunnerContract::class, 'terminate'),
+ PHP_INT_MAX
+ );
+ }
+
+ parent::tearDown();
+ }
+
+ public function test_it_registers_a_singleton_runner_with_contributed_tasks(): void {
+ $calls = [];
+
+ $this->container->register(ShutdownProvider::class);
+ $this->container->mergeArrayVar(ShutdownProvider::TASKS, [
+ new ShutdownTask(new CallbackTerminable(static function () use (&$calls): void {
+ $calls[] = 'terminated';
+ })),
+ ]);
+
+ $runner = $this->container->get(ShutdownRunnerContract::class);
+
+ $this->assertInstanceOf(ResponseFinishingRunner::class, $runner);
+ $this->assertSame($runner, $this->container->get(ShutdownRunnerContract::class));
+
+ $runner->terminate();
+
+ $this->assertSame(['terminated'], $calls);
+ }
+
+ public function test_duplicate_provider_registration_does_not_replace_the_runner(): void {
+ $this->container->register(ShutdownProvider::class);
+ $runner = $this->container->get(ShutdownRunnerContract::class);
+
+ $this->container->register(ShutdownProvider::class);
+
+ $this->assertSame($runner, $this->container->get(ShutdownRunnerContract::class));
+ }
+
+ public function test_it_injects_a_registered_psr_logger(): void {
+ $handler = new TestHandler();
+
+ $this->container->singleton(LoggerInterface::class, new Logger('shutdown', [$handler]));
+ $this->container->register(ShutdownProvider::class);
+
+ $this->container->get(ShutdownRunnerContract::class)->terminate();
+
+ $this->assertTrue($handler->hasDebugThatMatches('/Running shutdown tasks\./'));
+ }
+
+ public function test_it_runs_contributed_tasks_on_wordpress_shutdown(): void {
+ $calls = [];
+
+ $this->container->register(ShutdownProvider::class);
+ $callback = $this->container->callback(ShutdownRunnerContract::class, 'terminate');
+
+ $this->container->mergeArrayVar(ShutdownProvider::TASKS, [
+ new ShutdownTask(new CallbackTerminable(static function () use (&$calls): void {
+ $calls[] = 'terminated';
+ })),
+ ]);
+
+ $this->assertSame(PHP_INT_MAX, has_action('shutdown', $callback));
+
+ $callback();
+
+ $this->assertSame(['terminated'], $calls);
+ }
+}