Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions packages/core/src/Application.php
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,8 @@ class Application
/** @var ?Router */
private ?object $_router = null;

private bool $shutDown = false;

/** @var Router */
public object $router {
get => $this->_router ?? throw new RuntimeException(
Expand Down Expand Up @@ -277,6 +279,40 @@ public function initialize(
$this->bootstrapErrorHandler->unregister();
}

/**
* Undo the global state boot installed, so the process can go on without this application.
*
* Runs every module's `shutdown` callback (from module.php) in reverse module load order,
* the mirror of the boot callbacks, so handlers pushed onto PHP's global stacks come off
* in the order they went on. The errors modules unregister their error and exception
* handlers here. Then removes the bootstrap error handler if a failed boot left it installed.
*
* Call it when the application is done but the process is not: marko/testing calls it
* after each test that booted one, and a long-running worker can call it before it exits
* or reboots. Safe to call more than once; only the first call does anything.
*
* @throws BindingException|CircularDependencyException|ReflectionException|PluginException|ContainerExceptionInterface
*/
public function shutdown(): void
{
if ($this->shutDown) {
return;
}

$this->shutDown = true;

// initialize() never ran (or failed before the container existed): nothing booted
if (isset($this->container)) {
foreach (array_reverse($this->modules) as $module) {
if ($module->shutdown !== null) {
$this->container->call($module->shutdown);
}
}
}

$this->bootstrapErrorHandler->unregister();
}

/**
* Register PSR-4 autoloaders for non-vendor modules.
*
Expand Down
3 changes: 2 additions & 1 deletion packages/core/src/Module/ManifestParser.php
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ public function parse(
* Rebuild a manifest from its cached composer data and the module's live module.php.
*
* Never reads composer.json. module.php is still required, so its closures
* (bindings, boot callbacks) stay live.
* (bindings, boot and shutdown callbacks) stay live.
*
* @throws ModuleException If module.php has syntax errors or does not return an array
*/
Expand Down Expand Up @@ -93,6 +93,7 @@ private function build(
globalMiddleware: $moduleData['globalMiddleware'] ?? [],
extra: $extra,
discovery: $moduleData['discovery'] ?? [],
shutdown: $moduleData['shutdown'] ?? null,
);
}

Expand Down
1 change: 1 addition & 0 deletions packages/core/src/Module/ModuleDiscovery.php
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,7 @@ private function withPathAndSource(
globalMiddleware: $manifest->globalMiddleware,
extra: $manifest->extra,
discovery: $manifest->discovery,
shutdown: $manifest->shutdown,
);
}

Expand Down
4 changes: 3 additions & 1 deletion packages/core/src/Module/ModuleManifest.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
* Value object representing a discovered module.
*
* Combines data from composer.json (name, version, require)
* with Marko-specific config from module.php (enabled, sequence, bindings, boot).
* with Marko-specific config from module.php (enabled, sequence, bindings, boot, shutdown).
*/
readonly class ModuleManifest
{
Expand All @@ -29,6 +29,7 @@
* @param string $source Discovery source: vendor, modules, or app
* @param array<string, string> $autoload PSR-4 autoload configuration from composer.json (namespace => path)
* @param Closure|null $boot Boot callback to run after bindings are registered (from module.php). Parameters are auto-injected from the container — type-hint any registered dependency, including ContainerInterface.
* @param Closure|null $shutdown Shutdown callback run by Application::shutdown() (from module.php), in reverse module load order, to undo what the boot callback set up (global handlers, open resources). Parameters are auto-injected like boot's.
* @param array<int, class-string> $globalMiddleware Global HTTP middleware classes declared by this module (from module.php). Order across modules follows DependencyResolver (composer require + sequence: { after, before }); order within a module follows array declaration order.
* @param array<string, mixed> $extra Raw extra data from composer.json (vendor/Marko-specific metadata)
* @param array<int, class-string> $discovery Discovery cache contributors declared by this module (from module.php); each must implement DiscoveryCacheContributorInterface
Expand All @@ -49,5 +50,6 @@ public function __construct(
public array $globalMiddleware = [],
public array $extra = [],
public array $discovery = [],
public ?Closure $shutdown = null,
) {}
}
163 changes: 163 additions & 0 deletions packages/core/tests/Unit/ApplicationShutdownTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
<?php

declare(strict_types=1);

use Marko\Core\Application;
use Marko\Core\Environment\AppEnvironment;

/**
* A project with two app modules: app/alpha loads first, app/omega after it.
* Each records its boot and shutdown callbacks in $GLOBALS[$key].
*
* @return array{base: string, key: string}
*/
function applicationShutdownProject(): array
{
$id = bin2hex(random_bytes(6));
$base = sys_get_temp_dir() . "/marko-application-shutdown-$id";
$key = "__marko_application_shutdown_$id";

foreach (["$base/vendor", "$base/app/alpha", "$base/app/omega"] as $dir) {
mkdir($dir, 0755, true);
}

foreach (['alpha' => [], 'omega' => ['after' => ['app/alpha']]] as $name => $sequence) {
file_put_contents("$base/app/$name/composer.json", json_encode([
'name' => "app/$name",
'extra' => ['marko' => ['module' => true]],
]));
$sequenceCode = var_export($sequence, true);
file_put_contents("$base/app/$name/module.php", <<<PHP
<?php

use Marko\\Core\\Environment\\AppEnvironment;

return [
'sequence' => $sequenceCode,
'boot' => function (): void { \$GLOBALS['$key'][] = '$name boot'; },
'shutdown' => function (AppEnvironment \$appEnvironment): void {
\$GLOBALS['$key'][] = '$name shutdown (' . \$appEnvironment::class . ')';
},
];
PHP);
}

return ['base' => $base, 'key' => $key];
}

function applicationShutdownCleanup(
string $dir,
): void {
if (!is_dir($dir)) {
return;
}

$items = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator($dir, RecursiveDirectoryIterator::SKIP_DOTS),
RecursiveIteratorIterator::CHILD_FIRST,
);

foreach ($items as $item) {
$item->isDir() ? rmdir($item->getPathname()) : unlink($item->getPathname());
}

rmdir($dir);
}

/**
* The active exception handlers, bottom of the stack first, read without changing them.
*
* @return list<callable>
*/
function applicationShutdownExceptionHandlers(): array
{
$handlers = [];

while (($handler = set_exception_handler(null)) !== null) {
restore_exception_handler();
$handlers[] = $handler;
restore_exception_handler();
}

restore_exception_handler();
$handlers = array_reverse($handlers);

foreach ($handlers as $handler) {
set_exception_handler($handler);
}

return $handlers;
}

describe('Application::shutdown()', function (): void {
beforeEach(function (): void {
$this->savedEnv = [];

foreach (['APP_ENV', 'MARKO_ENV', 'DISCOVERY_CACHE_ENABLED'] as $key) {
$this->savedEnv[$key] = [array_key_exists($key, $_ENV) ? $_ENV[$key] : null, getenv($key)];
unset($_ENV[$key]);
putenv($key);
}

$_ENV['APP_ENV'] = 'production';
$this->project = applicationShutdownProject();
$base = $this->project['base'];
$this->app = new Application(vendorPath: "$base/vendor", modulesPath: "$base/modules", appPath: "$base/app");
});

afterEach(function (): void {
foreach ($this->savedEnv as $key => [$env, $process]) {
unset($_ENV[$key]);
putenv($key);

if ($env !== null) {
$_ENV[$key] = $env;
}

if ($process !== false) {
putenv("$key=$process");
}
}

unset($GLOBALS[$this->project['key']]);
applicationShutdownCleanup($this->project['base']);
});

it('runs every module shutdown callback in reverse load order, with injected parameters', function (): void {
$this->app->initialize(false);
$this->app->shutdown();

expect($GLOBALS[$this->project['key']])->toBe([
'alpha boot',
'omega boot',
'omega shutdown (' . AppEnvironment::class . ')',
'alpha shutdown (' . AppEnvironment::class . ')',
]);
});

it('runs the shutdown callbacks only once when called again', function (): void {
$this->app->initialize(false);
$this->app->shutdown();
$this->app->shutdown();

expect(array_filter(
$GLOBALS[$this->project['key']],
fn (string $entry): bool => str_contains($entry, 'shutdown'),
))->toHaveCount(2);
});

it('does nothing for an application that was never initialized', function (): void {
$this->app->shutdown();

expect($GLOBALS[$this->project['key']] ?? [])->toBe([]);
});

it('removes the bootstrap error handler when it is still installed', function (): void {
$before = applicationShutdownExceptionHandlers();
$this->app->bootstrapErrorHandler->register();

$this->app->shutdown();

expect(applicationShutdownExceptionHandlers())->toBe($before);
});
});
4 changes: 3 additions & 1 deletion packages/core/tests/Unit/Module/ManifestParserCachedTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ function cachedManifestCleanup(

it('keeps module.php closures live when building a manifest from a cached module', function (): void {
$dir = cachedManifestModule(
"<?php return ['boot' => fn (): string => 'booted', 'bindings' => ['A' => fn (): string => 'b']];",
"<?php return ['boot' => fn (): string => 'booted', 'shutdown' => fn (): string => 'shut down', 'bindings' => ['A' => fn (): string => 'b']];",
);

$manifest = (new ManifestParser())->parseCached(new CachedModule(
Expand All @@ -95,6 +95,8 @@ function cachedManifestCleanup(

expect($manifest->boot)->toBeInstanceOf(Closure::class)
->and(($manifest->boot)())->toBe('booted')
->and($manifest->shutdown)->toBeInstanceOf(Closure::class)
->and(($manifest->shutdown)())->toBe('shut down')
->and($manifest->bindings['A'])->toBeInstanceOf(Closure::class);
});

Expand Down
22 changes: 21 additions & 1 deletion packages/docs-markdown/docs/packages/core.md
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,7 @@ Create a directory in `app/` with a `composer.json`:
app/
mymodule/
composer.json # Required: name, autoload
module.php # Optional: enabled, sequence, bindings, singletons, boot, globalMiddleware, discovery
module.php # Optional: enabled, sequence, bindings, singletons, boot, shutdown, globalMiddleware, discovery
src/
MyService.php
```
Expand Down Expand Up @@ -160,6 +160,26 @@ readonly class CheckWidgetSetup

Under PHP-FPM every request is a boot, so keep these observers cheap. `marko/authorization` uses this event to [check `#[Can]` routes at boot](/docs/packages/authorization/#cost-on-routes-without-can).

#### Shutting down

A `boot` callback that changes process-wide state (pushes a global error or exception handler, opens a resource) has a matching `shutdown` callback that undoes it. `Application::shutdown()` runs every module's `shutdown` callback in reverse module load order, so handlers come off PHP's stacks in the reverse order they went on. Parameters are auto-injected like `boot`'s:

```php title="module.php"
return [
'singletons' => [
ErrorHandlerInterface::class, // shared, so shutdown gets the instance boot registered
],
'boot' => function (ErrorHandlerInterface $handler): void {
$handler->register();
},
'shutdown' => function (ErrorHandlerInterface $handler): void {
$handler->unregister();
},
];
```

Call `$app->shutdown()` when the application is done but the PHP process is not. [marko/testing](/docs/packages/testing/#shutdown-after-each-test) does it after every test that booted an application, so the test leaves PHP's error and exception handlers as it found them. A long-running worker can call it before it exits or boots a fresh application. Only the first call does anything. It also removes the [bootstrap error handler](#errors-during-boot) if a failed boot left it installed. Under PHP-FPM there is nothing to call: the process ends with the request.

### Application Environment

`AppEnvironment` is the single answer to "which environment is this application running in?". `Application` registers one shared instance in the container at boot, so any class or boot callback can type-hint it:
Expand Down
2 changes: 1 addition & 1 deletion packages/docs-markdown/docs/packages/errors-advanced.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ Pretty error pages with syntax-highlighted code, stack traces, and request detai
composer require marko/errors-advanced
```

This replaces the default `marko/errors-simple` handler with a more detailed error display. The package binds `AdvancedErrorHandler` to `ErrorHandlerInterface` and registers it automatically via its `module.php` boot callback.
This replaces the default `marko/errors-simple` handler with a more detailed error display. The package binds `AdvancedErrorHandler` to `ErrorHandlerInterface` and registers it automatically via its `module.php` boot callback. Like errors-simple, it binds the handler as a singleton and unregisters it from its `shutdown` callback when [`Application::shutdown()`](/docs/packages/core/#shutting-down) runs.

## Usage

Expand Down
2 changes: 1 addition & 1 deletion packages/docs-markdown/docs/packages/errors-simple.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ Non-fatal PHP errors (warnings, notices, deprecations) are reported via `error_l
composer require marko/errors-simple
```

The handler registers automatically via module boot --- no configuration required.
The handler registers automatically via module boot --- no configuration required. `ErrorHandlerInterface` is bound as a singleton, and the module's `shutdown` callback unregisters the handler when [`Application::shutdown()`](/docs/packages/core/#shutting-down) runs (marko/testing does this after every test that booted the application).

## Usage

Expand Down
22 changes: 20 additions & 2 deletions packages/docs-markdown/docs/packages/testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -502,6 +502,17 @@ $client->withCookie('locale', 'nl')->get('/')->assertSee('Welkom');

Use `TestClient::forApplication($app)` to wrap an `Application` you booted yourself. `TestClient::boot()` throws when the base path does not exist.

### Shutdown after each test

Booting runs every module's `boot` callback, and an errors module ([marko/errors-simple](/docs/packages/errors-simple/) or [marko/errors-advanced](/docs/packages/errors-advanced/)) uses it to install a global error handler and exception handler. Left in place, they would make PHPUnit report every HTTP test as risky ("did not remove its own error handlers") and pile up within a process.

`marko/testing` owns the boot, so it owns the teardown. Every application booted by `TestClient::boot()` or `TestDatabase::boot()` is tracked in `Marko\Testing\BootedApplications`, and after each test it calls [`Application::shutdown()`](/docs/packages/core/#shutting-down) on each one. That runs the modules' `shutdown` callbacks, which remove the handlers, so the test leaves the global error and exception handler stacks exactly as it found them. This happens automatically:

- **Pest**: `marko/testing` declares a second Pest plugin, `Marko\Testing\Pest\ShutdownApplicationsPlugin`, which runs the shutdown after every test, plain `it()` tests included. There is nothing to add to `Pest.php`.
- **PHPUnit**: `Marko\Testing\TestCase::tearDown()` runs it. If you override `tearDown()`, call `parent::tearDown()`.

An application you boot yourself, with `Application::boot()` or for `TestClient::forApplication()`, is not tracked. Call `$app->shutdown()` when the test is done with it.

### Pest setup

Create one client per test, so state from one test (cookies, `actingAs()`, headers) never reaches the next:
Expand Down Expand Up @@ -748,7 +759,7 @@ Database tests need `marko/database` and a driver. `marko/testing` only suggests
composer require --dev marko/database-pgsql # or marko/database-mysql
```

`TestDatabase::boot($basePath)` boots your application once per process (the same `Application::boot()` that `TestClient::boot()` uses) and applies the committed migrations once with `Migrator::migrate()`. Every later call for the same base path returns the same instance, so a suite pays for boot and migrations once, not once per test. Pair it with one of two isolation strategies:
`TestDatabase::boot($basePath)` boots your application once per process (the same `Application::boot()` that `TestClient::boot()` uses) and applies the committed migrations once with `Migrator::migrate()`. Every later call for the same base path returns the same instance, so a suite pays for boot and migrations once, not once per test. The application is [shut down](#shutdown-after-each-test) after the test that first booted it, which removes its global error handlers; it stays usable, so later tests keep sharing it. Pair it with one of two isolation strategies:

| Strategy | Per test | Use it for |
|----------|----------|------------|
Expand Down Expand Up @@ -890,7 +901,7 @@ The expectations register automatically. `marko/testing` declares a Pest plugin
The expectations need Pest 4 (`composer require --dev pestphp/pest`). The fakes and their `assert*()` methods work with plain PHPUnit.

:::note
If an expectation such as `toHavePushed` is reported as an undefined method, Pest's plugin list is stale. Run `composer dump-autoload` to regenerate `vendor/pest-plugins.json`, and make sure `pestphp/pest-plugin` is allowed under `config.allow-plugins`.
If an expectation such as `toHavePushed` is reported as an undefined method, or tests that boot the application are reported as risky, Pest's plugin list is stale. Run `composer dump-autoload` to regenerate `vendor/pest-plugins.json`, and make sure `pestphp/pest-plugin` is allowed under `config.allow-plugins`.
:::

Use them in tests:
Expand Down Expand Up @@ -1351,6 +1362,13 @@ public function assertNotProduction(): void;
public function assertDisposable(string $operation): void;
```

### BootedApplications

```php
public static function track(Application $application): void;
public static function shutdownAll(): void;
```

### RefreshDatabase

```php
Expand Down
Loading
Loading