From ffb3f0db4782f115d968ef71655eca51aeb13415 Mon Sep 17 00:00:00 2001
From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com>
Date: Sat, 3 Oct 2026 14:36:37 +0000
Subject: [PATCH 01/18] Port Inertia redirect callbacks, previous locations and
big integers
Ports inertiajs/inertia-laravel #915, #888 and #904. They share the
middleware tests, the Inertia config file and the frontend
documentation, so they land together.
#915: Inertia answers fragment redirects and Inertia::location() with a
409 response that tells the client to perform the redirect itself.
Deferred callbacks only run after responses below 400, so callbacks
registered with defer() were skipped even though the request
succeeded. A new EnsureDeferredCallbacksRun middleware, prepended to
the global middleware stack by the service provider, marks the pending
callbacks to always run on these responses. Asset version mismatch
reloads and failed responses still skip them. The middleware's own
per-request cost is within benchmark noise.
#888: The new store_previous_url option makes the Inertia middleware
store the URL and route name of Inertia visits as the session's
previous location, which the session middleware skips for AJAX
requests. Only GET visits are stored; prefetch requests, precognitive
requests and partial reloads of the rendered component are excluded.
Applications may override shouldStoreCurrentUrl() to change which
visits are stored. Upstream's Laravel 11 compatibility checks are not
ported.
#904: The new preserve_big_integers option, or preserveBigIntegers()
on a single response, sends integers outside JavaScript's safe range
in props and flash data as {"$bigint": "..."} markers and flags the
page, so the Inertia client (3.8 or later) revives them as native
BigInt values. It is disabled by default.
Hypervel adaptations:
- Hypervel encodes the root view's page JSON with JSON_THROW_ON_ERROR,
so the self-referencing object and pure enum tests expect that JSON
error where upstream renders false.
- frontend.md gains Previous URL and Big Integers sections, adapted
from inertiajs/docs cf513d8ffc.
Upstream reference: inertiajs/inertia-laravel 3.x at 4da52b72da.
Validation: upstream tests ported to MiddlewareTest,
InertiaServiceProviderTest, PropsResolverTest and ResponseFactoryTest,
plus the Inertia suite and PHPStan.
---
src/docs/frontend.md | 58 +++
src/inertia/config/inertia.php | 26 ++
src/inertia/src/InertiaServiceProvider.php | 5 +-
src/inertia/src/Middleware.php | 53 +++
.../Middleware/EnsureDeferredCallbacksRun.php | 57 +++
src/inertia/src/PreservesBigIntegers.php | 148 ++++++++
src/inertia/src/Response.php | 34 +-
src/inertia/src/ResponseFactory.php | 1 +
.../PrecognitiveRequestMiddleware.php | 27 ++
.../StorePartialReloadsMiddleware.php | 20 +
.../WithoutPreviousLocationMiddleware.php | 20 +
tests/Inertia/InertiaServiceProviderTest.php | 19 +-
tests/Inertia/MiddlewareTest.php | 347 ++++++++++++++++++
tests/Inertia/PropsResolverTest.php | 152 +++++++-
tests/Inertia/ResponseFactoryTest.php | 96 ++++-
15 files changed, 1051 insertions(+), 12 deletions(-)
create mode 100644 src/inertia/src/Middleware/EnsureDeferredCallbacksRun.php
create mode 100644 src/inertia/src/PreservesBigIntegers.php
create mode 100644 tests/Inertia/Fixtures/PrecognitiveRequestMiddleware.php
create mode 100644 tests/Inertia/Fixtures/StorePartialReloadsMiddleware.php
create mode 100644 tests/Inertia/Fixtures/WithoutPreviousLocationMiddleware.php
diff --git a/src/docs/frontend.md b/src/docs/frontend.md
index dd625d179e..4453e50e93 100644
--- a/src/docs/frontend.md
+++ b/src/docs/frontend.md
@@ -143,6 +143,64 @@ Your local environment is always allowed, so a gate can never lock you out of De
The gate only controls who may view entries. Requests are recorded no matter who makes them, so only enable the recorder outside your local environment when untrusted visitors can't reach the application.
+#### Previous URL
+
+Hypervel's session middleware doesn't store the previous URL and route for Inertia visits, since they are sent as AJAX requests, so `session()->previousUrl()`, `session()->previousUri()`, and `session()->previousRoute()` return the last full page load. The `back()` helper is unaffected, as it resolves the previous location from the request's `Referer` header. You may enable the `store_previous_url` option in your application's `config/inertia.php` configuration file to store the previous location for Inertia visits as well:
+
+```php
+'store_previous_url' => true,
+```
+
+Prefetch requests and [partial reloads](https://inertiajs.com/docs/partial-reloads) are excluded, since deferred props, polling, and infinite scroll requests aren't navigations the user came from. You may customize which visits are stored by overriding the `shouldStoreCurrentUrl` method in your `HandleInertiaRequests` middleware:
+
+```php
+use Hypervel\Http\Request;
+use Symfony\Component\HttpFoundation\Response;
+
+/**
+ * Determine if the visit should be stored as the previous location.
+ */
+public function shouldStoreCurrentUrl(Request $request, Response $response): bool
+{
+ return parent::shouldStoreCurrentUrl($request, $response)
+ && ! $request->routeIs('admin.*');
+}
+```
+
+#### Big Integers
+
+JavaScript rounds integers outside its safe range while parsing JSON, so an ID such as `900719925474099988` from a 64-bit database column reaches your components as `900719925474100000`. Inertia can keep these values exact by delivering them as native `BigInt` values. This requires the Inertia client-side adapter at `^3.8`, with nothing to configure on the client.
+
+Big integer support is disabled by default. You may enable it for every response using the `preserve_big_integers` option in your application's `config/inertia.php` configuration file, or the `INERTIA_PRESERVE_BIG_INTEGERS` environment variable:
+
+```ini
+INERTIA_PRESERVE_BIG_INTEGERS=true
+```
+
+You may also enable it for a single response using the `preserveBigIntegers` method. Passing `false` opts a single response out when the option is enabled:
+
+```php
+return Inertia::render('orders/show', [
+ 'order' => $order,
+])->preserveBigIntegers();
+
+return Inertia::render('reports/index', $props)->preserveBigIntegers(false);
+```
+
+Only integers outside the safe range become a `BigInt`, so the same prop may arrive as a number or a `BigInt`, depending on its value. Flash data receives the same treatment as props. Arrays, models, collections, API resources, `JsonSerializable` values, and the public properties of your own classes are inspected for large integers, while built-in PHP classes such as `DateTime` are left untouched.
+
+A `BigInt` submitted through the router, a form, or Precognition is sent as its digits, so your controller receives a numeric string that the `integer` validation rule and the request's `integer` method handle as usual. The `useHttp` hook does not convert `BigInt` values, so convert them to strings before sending them.
+
+Inertia's testing helpers turn big integers back into integers, so you may assert against the same values you passed to the response:
+
+```php
+use Hypervel\Inertia\Testing\AssertableInertia;
+
+$response->assertInertia(fn (AssertableInertia $page) => $page
+ ->where('order.id', 900719925474099988)
+);
+```
+
### Starter Kits
diff --git a/src/inertia/config/inertia.php b/src/inertia/config/inertia.php
index 7c558c1aeb..15afbd00e2 100644
--- a/src/inertia/config/inertia.php
+++ b/src/inertia/config/inertia.php
@@ -147,6 +147,19 @@
'expose_shared_prop_keys' => true,
+ /*
+ |--------------------------------------------------------------------------
+ | Previous URL
+ |--------------------------------------------------------------------------
+ |
+ | Hypervel's session middleware doesn't store the previous URL and route for
+ | Inertia visits, as they are sent as AJAX requests. Enable this option to
+ | store them for client-side visits as well, excluding partial reloads.
+ |
+ */
+
+ 'store_previous_url' => false,
+
/*
|--------------------------------------------------------------------------
| History
@@ -220,4 +233,17 @@
],
],
],
+
+ /*
+ |--------------------------------------------------------------------------
+ | Big Integers
+ |--------------------------------------------------------------------------
+ |
+ | When enabled, integers outside JavaScript's safe range arrive in the
+ | frontend as native BigInt values instead of losing precision. Single
+ | responses may opt in or out using the `preserveBigIntegers` method.
+ |
+ */
+
+ 'preserve_big_integers' => (bool) env('INERTIA_PRESERVE_BIG_INTEGERS', false),
];
diff --git a/src/inertia/src/InertiaServiceProvider.php b/src/inertia/src/InertiaServiceProvider.php
index 0d09071813..ab1c0f31f7 100644
--- a/src/inertia/src/InertiaServiceProvider.php
+++ b/src/inertia/src/InertiaServiceProvider.php
@@ -66,7 +66,7 @@ public function register(): void
public function boot(HttpFactory $http, Repository $config): void
{
$this->registerConsoleCommands();
- $this->pushRedirectMiddleware();
+ $this->registerRedirectMiddleware();
// A null timeout is left out so the HTTP client's global options apply.
$http->registerConnection(HttpGateway::CONNECTION, array_filter([
@@ -82,10 +82,11 @@ public function boot(HttpFactory $http, Repository $config): void
/**
* Register the global redirect middleware for Inertia requests.
*/
- protected function pushRedirectMiddleware(): void
+ protected function registerRedirectMiddleware(): void
{
$this->callAfterResolving(HttpKernelContract::class, function (HttpKernelContract $kernel) {
$kernel->pushMiddleware(Middleware\EnsureGetOnRedirect::class);
+ $kernel->prependMiddleware(Middleware\EnsureDeferredCallbacksRun::class);
});
}
diff --git a/src/inertia/src/Middleware.php b/src/inertia/src/Middleware.php
index 9c7b483388..3490197049 100644
--- a/src/inertia/src/Middleware.php
+++ b/src/inertia/src/Middleware.php
@@ -5,12 +5,14 @@
namespace Hypervel\Inertia;
use Closure;
+use Hypervel\Http\JsonResponse;
use Hypervel\Http\Request;
use Hypervel\Inertia\DevTools\DevTools;
use Hypervel\Inertia\Ssr\ExcludesSsrPaths;
use Hypervel\Inertia\Ssr\Gateway;
use Hypervel\Inertia\Support\Header;
use Hypervel\Inertia\Support\SessionKey;
+use Hypervel\Routing\Route;
use Hypervel\Session\Store;
use Hypervel\Support\Facades\Redirect;
use Hypervel\Support\MessageBag;
@@ -174,6 +176,8 @@ public function handle(Request $request, Closure $next): Response
return $response;
}
+ $this->storeCurrentUrl($request, $response);
+
if ($request->method() === 'GET' && $request->header(Header::VERSION, '') !== Inertia::getVersion()) {
$response = $this->onVersionChange($request, $response);
}
@@ -197,6 +201,55 @@ public function handle(Request $request, Closure $next): Response
return $response;
}
+ /**
+ * Store the current URL and route as the previous location, which Hypervel's
+ * session middleware skips for Inertia visits.
+ */
+ protected function storeCurrentUrl(Request $request, Response $response): void
+ {
+ if (! $this->shouldStoreCurrentUrl($request, $response)) {
+ return;
+ }
+
+ /** @var Store $session */
+ $session = $request->session();
+ $session->setPreviousUrl($request->fullUrl());
+ $session->setPreviousRoute($request->route()?->getName());
+ }
+
+ /**
+ * Determine if the visit should be stored as the previous location. Partial
+ * reloads are excluded, since deferred props, polling, and infinite scroll
+ * requests aren't navigations the user came from.
+ */
+ public function shouldStoreCurrentUrl(Request $request, Response $response): bool
+ {
+ if (! config()->boolean('inertia.store_previous_url')
+ || ! $request->hasSession()
+ || ! $request->isMethod('GET')
+ || ! $request->route() instanceof Route
+ || ! $request->ajax()
+ || $request->prefetch()
+ || $request->isPrecognitive()) {
+ return false;
+ }
+
+ return ! $this->isPartialReload($request, $response);
+ }
+
+ /**
+ * Determine if the request is a partial reload of the component that was rendered.
+ */
+ protected function isPartialReload(Request $request, Response $response): bool
+ {
+ if (! $component = $request->header(Header::PARTIAL_COMPONENT)) {
+ return false;
+ }
+
+ return $response instanceof JsonResponse
+ && $component === data_get($response->getOriginalContent(), 'component');
+ }
+
/**
* Add the Inertia header to the response's Vary list.
*/
diff --git a/src/inertia/src/Middleware/EnsureDeferredCallbacksRun.php b/src/inertia/src/Middleware/EnsureDeferredCallbacksRun.php
new file mode 100644
index 0000000000..ef09f5ad0b
--- /dev/null
+++ b/src/inertia/src/Middleware/EnsureDeferredCallbacksRun.php
@@ -0,0 +1,57 @@
+isClientRedirect($response)) {
+ return $response;
+ }
+
+ $callbacks = app(DeferredCallbackCollection::class);
+
+ for ($index = 0, $count = count($callbacks); $index < $count; ++$index) {
+ $callbacks[$index]->always();
+ }
+
+ return $response;
+ }
+
+ /**
+ * Determine if the response instructs the client to perform the redirect itself.
+ */
+ protected function isClientRedirect(Response $response): bool
+ {
+ if ($response->getStatusCode() !== 409) {
+ return false;
+ }
+
+ if ($response->headers->has(Header::REDIRECT)) {
+ return true;
+ }
+
+ // A location visit caused by an asset version mismatch carries the new version
+ // and makes the client replay the request, so the callbacks registered by
+ // that replay run instead.
+ return $response->headers->has(Header::LOCATION)
+ && ! $response->headers->has(Header::VERSION);
+ }
+}
diff --git a/src/inertia/src/PreservesBigIntegers.php b/src/inertia/src/PreservesBigIntegers.php
new file mode 100644
index 0000000000..cbf7f45a7b
--- /dev/null
+++ b/src/inertia/src/PreservesBigIntegers.php
@@ -0,0 +1,148 @@
+preserveBigIntegers ? $this->encodeBigIntegers($value) : $value;
+ }
+
+ /**
+ * Wrap integers outside JavaScript's safe integer range as a marker so the
+ * frontend can revive them as native BigInt values without losing
+ * precision when the JSON response is parsed.
+ *
+ * @param null|SplObjectStorage $seen
+ */
+ protected function encodeBigIntegers(mixed $value, ?SplObjectStorage $seen = null): mixed
+ {
+ if (is_int($value)) {
+ return $value > static::MAX_SAFE_INTEGER || $value < -static::MAX_SAFE_INTEGER
+ ? [static::BIG_INTEGER_KEY => (string) $value]
+ : $value;
+ }
+
+ if (is_array($value)) {
+ return $this->encodeBigIntegersInArray($value, $seen);
+ }
+
+ if (! is_object($value)) {
+ return $value;
+ }
+
+ $seen ??= new SplObjectStorage;
+
+ // Only ancestors are tracked, so a self-reference stops here and is left
+ // for json_encode to report, while a value shared by two branches is
+ // still encoded in both.
+ if ($seen->offsetExists($value)) {
+ return $value;
+ }
+
+ $seen->offsetSet($value);
+
+ $encoded = $this->encodeBigIntegersInObject($value, $seen);
+
+ $seen->offsetUnset($value);
+
+ return $encoded;
+ }
+
+ /**
+ * Wrap the big integers held by the given array.
+ *
+ * @param array $value
+ * @param null|SplObjectStorage $seen
+ * @return array
+ */
+ protected function encodeBigIntegersInArray(array $value, ?SplObjectStorage $seen): array
+ {
+ foreach ($value as $key => $nested) {
+ $value[$key] = $this->encodeBigIntegers($nested, $seen);
+ }
+
+ return $value;
+ }
+
+ /**
+ * Wrap the big integers held by the given object, unwrapping it the same
+ * way json_encode would so the shape it emits is unchanged. Objects are
+ * cast back so they keep serializing as a JSON object, not an array.
+ *
+ * @param SplObjectStorage $seen
+ */
+ protected function encodeBigIntegersInObject(object $value, SplObjectStorage $seen): mixed
+ {
+ if ($value instanceof BackedEnum) {
+ return $this->encodeBigIntegers($value->value, $seen);
+ }
+
+ // A pure enum has no JSON representation. Walking its properties would
+ // invent one, so it is left for json_encode to reject as before.
+ if ($value instanceof UnitEnum) {
+ return $value;
+ }
+
+ if ($value instanceof JsonSerializable) {
+ return $this->encodeBigIntegers($value->jsonSerialize(), $seen);
+ }
+
+ // An internal class such as DateTime, or a class extending one, serializes
+ // through its own handler, so walking its public properties would change
+ // the shape rather than just the integers.
+ if (! $this->hasPlainJsonRepresentation($value)) {
+ return $value;
+ }
+
+ return (object) $this->encodeBigIntegersInArray(get_object_vars($value), $seen);
+ }
+
+ /**
+ * Determine if the object's public properties are what json_encode emits.
+ */
+ protected function hasPlainJsonRepresentation(object $value): bool
+ {
+ if ($value instanceof stdClass) {
+ return true;
+ }
+
+ foreach ([$value::class, ...class_parents($value)] as $class) {
+ if ((new ReflectionClass($class))->isInternal()) {
+ return false;
+ }
+ }
+
+ return true;
+ }
+}
diff --git a/src/inertia/src/Response.php b/src/inertia/src/Response.php
index b3b55568fe..61aba99833 100644
--- a/src/inertia/src/Response.php
+++ b/src/inertia/src/Response.php
@@ -22,6 +22,7 @@
class Response implements Responsable
{
use Macroable;
+ use PreservesBigIntegers;
/**
* The name of the root component.
@@ -93,6 +94,7 @@ public function __construct(
string $version = '',
bool $encryptHistory = false,
?Closure $urlResolver = null,
+ bool $preserveBigIntegers = false,
) {
$this->component = $component;
$this->sharedProps = $sharedProps;
@@ -103,6 +105,19 @@ public function __construct(
$this->preserveFragment = session()->pull(SessionKey::PRESERVE_FRAGMENT, false);
$this->encryptHistory = $encryptHistory;
$this->urlResolver = $urlResolver;
+ $this->preserveBigIntegers = $preserveBigIntegers;
+ }
+
+ /**
+ * Preserve integers outside JavaScript's safe range as BigInt values.
+ *
+ * @return $this
+ */
+ public function preserveBigIntegers(bool $preserve = true): self
+ {
+ $this->preserveBigIntegers = $preserve;
+
+ return $this;
}
/**
@@ -174,6 +189,8 @@ public function toResponse(Request $request): SymfonyResponse
$resolver = new PropsResolver($request, $this->component);
[$resolvedProps, $resolvedMetadata] = $resolver->resolve($this->sharedProps, $this->props);
+ $resolvedProps = $this->encodeBigIntegersWhenEnabled($resolvedProps);
+
$page = array_merge(
[
'component' => $this->component,
@@ -182,6 +199,7 @@ public function toResponse(Request $request): SymfonyResponse
'version' => $this->version,
],
$resolvedMetadata,
+ $this->resolvePreserveBigIntegers($request),
$this->resolveClearHistory($request),
$this->resolveEncryptHistory($request),
$this->resolveFlashData($request),
@@ -232,7 +250,21 @@ protected function resolveFlashData(Request $request): array
{
$flash = Inertia::pullFlashed($request);
- return $flash ? ['flash' => $flash] : [];
+ if (! $flash) {
+ return [];
+ }
+
+ return ['flash' => $this->encodeBigIntegersWhenEnabled($flash)];
+ }
+
+ /**
+ * Resolve the preserve big integers flag.
+ *
+ * @return array
+ */
+ protected function resolvePreserveBigIntegers(Request $request): array
+ {
+ return $this->preserveBigIntegers ? ['preserveBigIntegers' => true] : [];
}
/**
diff --git a/src/inertia/src/ResponseFactory.php b/src/inertia/src/ResponseFactory.php
index 5fc64c6e7a..c1ba795d67 100644
--- a/src/inertia/src/ResponseFactory.php
+++ b/src/inertia/src/ResponseFactory.php
@@ -351,6 +351,7 @@ public function render(mixed $component, mixed $props = []): Response
$this->getVersion(),
$state->encryptHistory ?? config()->boolean('inertia.history.encrypt', false),
$state->urlResolver,
+ preserveBigIntegers: config()->boolean('inertia.preserve_big_integers'),
);
DevTools::recorder()?->pageRendering($component, $response);
diff --git a/tests/Inertia/Fixtures/PrecognitiveRequestMiddleware.php b/tests/Inertia/Fixtures/PrecognitiveRequestMiddleware.php
new file mode 100644
index 0000000000..9204f0fc56
--- /dev/null
+++ b/tests/Inertia/Fixtures/PrecognitiveRequestMiddleware.php
@@ -0,0 +1,27 @@
+header('Precognition') === 'true') {
+ $request->attributes->set('precognitive', true);
+ }
+
+ /** @var Response $response */
+ $response = $next($request);
+
+ return $response;
+ }
+}
diff --git a/tests/Inertia/Fixtures/StorePartialReloadsMiddleware.php b/tests/Inertia/Fixtures/StorePartialReloadsMiddleware.php
new file mode 100644
index 0000000000..e31157a7df
--- /dev/null
+++ b/tests/Inertia/Fixtures/StorePartialReloadsMiddleware.php
@@ -0,0 +1,20 @@
+assertTrue($kernel->hasMiddleware(EnsureGetOnRedirect::class));
}
+ public function testDeferredCallbacksMiddlewareWrapsTheGlobalMiddlewareStack(): void
+ {
+ $kernel = $this->app->make(HttpKernelContract::class);
+
+ $this->assertTrue($kernel->hasMiddleware(EnsureDeferredCallbacksRun::class));
+ $this->assertSame(EnsureDeferredCallbacksRun::class, $kernel->getGlobalMiddleware()[0]);
+ }
+
public function testGatewayContractResolvesTheConcreteWorkerInstance(): void
{
$this->assertSame(
@@ -152,10 +161,14 @@ public function testRedirectMiddlewareRegistersThroughTheKernelContract(): void
->once()
->with(EnsureGetOnRedirect::class)
->andReturnSelf();
+ $kernel->shouldReceive('prependMiddleware')
+ ->once()
+ ->with(EnsureDeferredCallbacksRun::class)
+ ->andReturnSelf();
$this->app->instance(HttpKernelContract::class, $kernel);
(new InspectableInertiaServiceProvider($this->app))
- ->pushRedirectMiddlewareForTest();
+ ->registerRedirectMiddlewareForTest();
}
public function testRedirectResponseFromRateLimiterIsConvertedTo303(): void
@@ -186,8 +199,8 @@ class InspectableInertiaServiceProvider extends InertiaServiceProvider
/**
* Register redirect middleware for inspection.
*/
- public function pushRedirectMiddlewareForTest(): void
+ public function registerRedirectMiddlewareForTest(): void
{
- $this->pushRedirectMiddleware();
+ $this->registerRedirectMiddleware();
}
}
diff --git a/tests/Inertia/MiddlewareTest.php b/tests/Inertia/MiddlewareTest.php
index 8453712562..db06f4904b 100644
--- a/tests/Inertia/MiddlewareTest.php
+++ b/tests/Inertia/MiddlewareTest.php
@@ -4,11 +4,16 @@
namespace Hypervel\Tests\Inertia;
+use Hypervel\Contracts\Http\Kernel as HttpKernelContract;
use Hypervel\Filesystem\Filesystem;
+use Hypervel\Http\JsonResponse;
+use Hypervel\Http\RedirectResponse;
use Hypervel\Http\Request;
+use Hypervel\Http\Response as BaseResponse;
use Hypervel\Inertia\AlwaysProp;
use Hypervel\Inertia\Inertia;
use Hypervel\Inertia\Middleware;
+use Hypervel\Inertia\Response;
use Hypervel\Inertia\Ssr\HttpGateway;
use Hypervel\Inertia\Support\Header;
use Hypervel\Routing\Route as RouteInstance;
@@ -19,8 +24,11 @@
use Hypervel\Support\ViewErrorBag;
use Hypervel\Tests\Inertia\Fixtures\CustomUrlResolverMiddleware;
use Hypervel\Tests\Inertia\Fixtures\ExampleMiddleware;
+use Hypervel\Tests\Inertia\Fixtures\PrecognitiveRequestMiddleware;
use Hypervel\Tests\Inertia\Fixtures\SsrExceptMiddleware;
+use Hypervel\Tests\Inertia\Fixtures\StorePartialReloadsMiddleware;
use Hypervel\Tests\Inertia\Fixtures\WithAllErrorsMiddleware;
+use Hypervel\Tests\Inertia\Fixtures\WithoutPreviousLocationMiddleware;
use LogicException;
use PHPUnit\Framework\Attributes\After;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
@@ -512,6 +520,176 @@ public function testFlashDataIsPreservedOnNonInertiaRedirect(): void
]);
}
+ public function testInertiaVisitsAreStoredAsThePreviousUrlAndRoute(): void
+ {
+ config()->set('inertia.store_previous_url', true);
+
+ $this->preparePreviousLocationEndpoints();
+
+ $this->get('/initial')->assertOk();
+
+ $this->get('/users?filter=active', [
+ 'X-Inertia' => 'true',
+ 'X-Requested-With' => 'XMLHttpRequest',
+ ])->assertOk();
+
+ $this->assertPreviousLocation($this->baseUrl . '/users?filter=active', 'users.index');
+ }
+
+ public function testInertiaVisitsAreNotStoredAsThePreviousUrlAndRouteByDefault(): void
+ {
+ $this->preparePreviousLocationEndpoints();
+
+ $this->get('/initial')->assertOk();
+
+ $this->get('/users', [
+ 'X-Inertia' => 'true',
+ 'X-Requested-With' => 'XMLHttpRequest',
+ ])->assertOk();
+
+ $this->assertPreviousLocation($this->baseUrl . '/initial', 'initial');
+ }
+
+ public function testInertiaPrefetchVisitsAreNotStoredAsThePreviousUrlAndRoute(): void
+ {
+ config()->set('inertia.store_previous_url', true);
+
+ $this->preparePreviousLocationEndpoints();
+
+ $this->get('/initial')->assertOk();
+
+ $this->get('/users', [
+ 'X-Inertia' => 'true',
+ 'X-Requested-With' => 'XMLHttpRequest',
+ 'Purpose' => 'prefetch',
+ ])->assertOk();
+
+ $this->assertPreviousLocation($this->baseUrl . '/initial', 'initial');
+ }
+
+ public function testNonInertiaAjaxRequestsAreNotStoredAsThePreviousUrlAndRoute(): void
+ {
+ config()->set('inertia.store_previous_url', true);
+
+ $this->preparePreviousLocationEndpoints();
+
+ $this->get('/initial')->assertOk();
+
+ $this->get('/users', [
+ 'X-Requested-With' => 'XMLHttpRequest',
+ ])->assertOk();
+
+ $this->assertPreviousLocation($this->baseUrl . '/initial', 'initial');
+ }
+
+ public function testNonGetInertiaRequestsAreNotStoredAsThePreviousUrlAndRoute(): void
+ {
+ config()->set('inertia.store_previous_url', true);
+
+ $this->preparePreviousLocationEndpoints();
+
+ $this->get('/initial')->assertOk();
+
+ $this->post('/users', [], [
+ 'X-Inertia' => 'true',
+ 'X-Requested-With' => 'XMLHttpRequest',
+ ])->assertOk();
+
+ $this->assertPreviousLocation($this->baseUrl . '/initial', 'initial');
+ }
+
+ public function testPrecognitiveInertiaRequestsAreNotStoredAsThePreviousUrlAndRoute(): void
+ {
+ config()->set('inertia.store_previous_url', true);
+
+ $this->preparePreviousLocationEndpoints();
+
+ $this->get('/initial')->assertOk();
+
+ $this->get('/users', [
+ 'X-Inertia' => 'true',
+ 'X-Requested-With' => 'XMLHttpRequest',
+ 'Precognition' => 'true',
+ ])->assertSuccessful();
+
+ $this->assertPreviousLocation($this->baseUrl . '/initial', 'initial');
+ }
+
+ public function testPartialReloadsAreNotStoredAsThePreviousUrlAndRoute(): void
+ {
+ config()->set('inertia.store_previous_url', true);
+
+ $this->preparePreviousLocationEndpoints();
+
+ $this->get('/initial')->assertOk();
+
+ $this->get('/users', [
+ 'X-Inertia' => 'true',
+ 'X-Requested-With' => 'XMLHttpRequest',
+ ])->assertOk();
+
+ $this->get('/users?page=2', [
+ 'X-Inertia' => 'true',
+ 'X-Requested-With' => 'XMLHttpRequest',
+ 'X-Inertia-Partial-Component' => 'Users/Index',
+ 'X-Inertia-Partial-Data' => 'users',
+ ])->assertOk();
+
+ $this->assertPreviousLocation($this->baseUrl . '/users', 'users.index');
+ }
+
+ public function testPartialVisitsToAnotherComponentAreStoredAsThePreviousUrlAndRoute(): void
+ {
+ config()->set('inertia.store_previous_url', true);
+
+ $this->preparePreviousLocationEndpoints();
+
+ $this->get('/initial')->assertOk();
+
+ $this->get('/users', [
+ 'X-Inertia' => 'true',
+ 'X-Requested-With' => 'XMLHttpRequest',
+ 'X-Inertia-Partial-Component' => 'Dashboard',
+ 'X-Inertia-Partial-Data' => 'users',
+ ])->assertOk();
+
+ $this->assertPreviousLocation($this->baseUrl . '/users', 'users.index');
+ }
+
+ public function testPartialReloadsCanBeStoredAsThePreviousUrlAndRoute(): void
+ {
+ config()->set('inertia.store_previous_url', true);
+
+ $this->preparePreviousLocationEndpoints(StorePartialReloadsMiddleware::class);
+
+ $this->get('/initial')->assertOk();
+
+ $this->get('/users?page=2', [
+ 'X-Inertia' => 'true',
+ 'X-Requested-With' => 'XMLHttpRequest',
+ 'X-Inertia-Partial-Component' => 'Users/Index',
+ 'X-Inertia-Partial-Data' => 'users',
+ ])->assertOk();
+
+ $this->assertPreviousLocation($this->baseUrl . '/users?page=2', 'users.index');
+ }
+
+ public function testStoringThePreviousUrlAndRouteCanBeDisabled(): void
+ {
+ config()->set('inertia.store_previous_url', true);
+
+ $this->preparePreviousLocationEndpoints(WithoutPreviousLocationMiddleware::class);
+
+ $this->get('/initial')->assertOk();
+
+ $this->get('/users', [
+ 'X-Inertia' => 'true',
+ 'X-Requested-With' => 'XMLHttpRequest',
+ ])->assertOk();
+
+ $this->assertPreviousLocation($this->baseUrl . '/initial', 'initial');
+ }
+
public function testRedirectWithHashFragmentReturns409ForInertiaRequests(): void
{
Route::middleware([StartSession::class, Middleware::class])->get('/action', function () {
@@ -582,6 +760,137 @@ public function testRedirectWithHashFragmentIsNotInterceptedForPrefetchRequests(
$response->assertRedirect($this->baseUrl . '/article#section');
}
+ public function testDeferredCallbacksRunWhenAFragmentRedirectIsReturned(): void
+ {
+ $called = false;
+
+ Route::middleware([StartSession::class, Middleware::class])->post('/action', function () use (&$called): RedirectResponse {
+ defer(function () use (&$called): void {
+ $called = true;
+ });
+
+ return redirect('/article#section');
+ });
+
+ $response = $this->post('/action', [], [
+ 'X-Inertia' => 'true',
+ ]);
+
+ $response->assertStatus(409);
+ $response->assertHeader('X-Inertia-Redirect', $this->baseUrl . '/article#section');
+ $this->assertTrue($called);
+ }
+
+ public function testDeferredCallbacksRunWhenALocationVisitIsReturned(): void
+ {
+ $called = false;
+
+ Route::middleware([StartSession::class, Middleware::class])->post('/action', function () use (&$called): SymfonyResponse {
+ defer(function () use (&$called): void {
+ $called = true;
+ });
+
+ return Inertia::location('https://inertiajs.com');
+ });
+
+ $response = $this->post('/action', [], [
+ 'X-Inertia' => 'true',
+ ]);
+
+ $response->assertStatus(409);
+ $response->assertHeader('X-Inertia-Location', 'https://inertiajs.com');
+ $this->assertTrue($called);
+ }
+
+ public function testDeferredCallbacksRunWhenTheMiddlewareIsRegisteredGlobally(): void
+ {
+ $called = false;
+
+ $kernel = $this->app->make(HttpKernelContract::class);
+ $kernel->pushMiddleware(Middleware::class);
+
+ Route::middleware(StartSession::class)->post('/action', function () use (&$called): RedirectResponse {
+ defer(function () use (&$called): void {
+ $called = true;
+ });
+
+ return redirect('/article#section');
+ });
+
+ $response = $this->post('/action', [], [
+ 'X-Inertia' => 'true',
+ ]);
+
+ $response->assertStatus(409);
+ $this->assertTrue($called);
+ }
+
+ public function testDeferredCallbacksAreSkippedWhenAControlHeaderIsSetOnAFailedResponse(): void
+ {
+ $called = false;
+
+ Route::middleware([StartSession::class, Middleware::class])->post('/action', function () use (&$called): BaseResponse {
+ defer(function () use (&$called): void {
+ $called = true;
+ });
+
+ return response('', 500, ['X-Inertia-Redirect' => '/article#section']);
+ });
+
+ $response = $this->post('/action', [], [
+ 'X-Inertia' => 'true',
+ ]);
+
+ $response->assertStatus(500);
+ $this->assertFalse($called);
+ }
+
+ public function testDeferredCallbacksAreSkippedOnAVersionMismatchReload(): void
+ {
+ $called = false;
+
+ $middleware = new ExampleMiddleware('1234');
+
+ Route::middleware(StartSession::class)->get('/', function (Request $request) use ($middleware, &$called): SymfonyResponse {
+ return $middleware->handle($request, function (Request $request) use (&$called): SymfonyResponse {
+ defer(function () use (&$called): void {
+ $called = true;
+ });
+
+ return Inertia::render('User/Edit')->toResponse($request);
+ });
+ });
+
+ $response = $this->get('/', [
+ 'X-Inertia' => 'true',
+ 'X-Inertia-Version' => '4321',
+ ]);
+
+ $response->assertStatus(409);
+ $response->assertHeader('X-Inertia-Location', $this->baseUrl);
+ $this->assertFalse($called);
+ }
+
+ public function testDeferredCallbacksAreSkippedOnAFailedResponse(): void
+ {
+ $called = false;
+
+ Route::middleware([StartSession::class, Middleware::class])->post('/action', function () use (&$called): never {
+ defer(function () use (&$called): void {
+ $called = true;
+ });
+
+ abort(500);
+ });
+
+ $response = $this->post('/action', [], [
+ 'X-Inertia' => 'true',
+ ]);
+
+ $response->assertStatus(500);
+ $this->assertFalse($called);
+ }
+
public function testMiddlewareRegistersSsrExceptPaths(): void
{
$middleware = new SsrExceptMiddleware;
@@ -666,4 +975,42 @@ private function prepareMockEndpoint(int|string|null $version = null, array $sha
});
});
}
+
+ /**
+ * Assert the previous URL and route stored in the session.
+ */
+ private function assertPreviousLocation(string $url, string $route): void
+ {
+ $response = $this->post('/inspect-previous');
+
+ $response->assertJsonPath('url', $url);
+ $response->assertJsonPath('route', $route);
+ }
+
+ /**
+ * Register the routes used by the previous location tests.
+ *
+ * @param class-string $middleware
+ */
+ private function preparePreviousLocationEndpoints(string $middleware = Middleware::class): void
+ {
+ Route::middleware([StartSession::class, $middleware])->get('/initial', function (): BaseResponse {
+ return response('Initial page');
+ })->name('initial');
+
+ Route::middleware([
+ StartSession::class,
+ PrecognitiveRequestMiddleware::class,
+ $middleware,
+ ])->match(['GET', 'POST'], '/users', function (): Response {
+ return Inertia::render('Users/Index');
+ })->name('users.index');
+
+ Route::middleware(StartSession::class)->post('/inspect-previous', function (Request $request): JsonResponse {
+ return response()->json([
+ 'url' => $request->session()->previousUrl(),
+ 'route' => $request->session()->previousRoute(),
+ ]);
+ });
+ }
}
diff --git a/tests/Inertia/PropsResolverTest.php b/tests/Inertia/PropsResolverTest.php
index d685a52546..411824f071 100644
--- a/tests/Inertia/PropsResolverTest.php
+++ b/tests/Inertia/PropsResolverTest.php
@@ -4,11 +4,14 @@
namespace Hypervel\Tests\Inertia;
+use ArrayObject;
+use DateTimeImmutable;
use Hypervel\Http\JsonResponse;
use Hypervel\Http\Request;
use Hypervel\Http\Response as BaseResponse;
use Hypervel\Inertia\DevTools\RequestRecorder;
use Hypervel\Inertia\Inertia;
+use Hypervel\Inertia\InertiaState;
use Hypervel\Inertia\MergeProp;
use Hypervel\Inertia\PropertyContext;
use Hypervel\Inertia\ProvidesInertiaProperties;
@@ -17,8 +20,11 @@
use Hypervel\Inertia\RenderContext;
use Hypervel\Inertia\Response;
use Hypervel\Inertia\ScrollProp;
+use Hypervel\Tests\Inertia\Fixtures\Enums\UnitEnum;
+use Hypervel\View\ViewException;
use JsonSerializable;
use RuntimeException;
+use stdClass;
class PropsResolverTest extends TestCase
{
@@ -1420,15 +1426,157 @@ public function jsonSerialize(): array
$this->assertSame('deferred value', $page['props']['dto']['thing']);
}
+ public function testBigIntegersInsidePlainObjectsAreWrappedWhenEnabled(): void
+ {
+ $page = $this->makePage(Request::create('/'), [
+ 'object' => (object) ['id' => 900719925474099988],
+ 'dto' => new class implements JsonSerializable {
+ /**
+ * Get the JSON serializable representation of the object.
+ */
+ public function jsonSerialize(): array
+ {
+ return ['id' => 900719925474099988];
+ }
+ },
+ ], preserveBigIntegers: true);
+
+ $this->assertSame(['$bigint' => '900719925474099988'], $page['props']['object']->id);
+ $this->assertSame(['$bigint' => '900719925474099988'], $page['props']['dto']['id']);
+ }
+
+ public function testBigIntegersInsideArbitraryObjectsAreWrappedWhenEnabled(): void
+ {
+ $money = new class {
+ public int $cents = 900719925474099988;
+ };
+
+ $page = $this->makePage(Request::create('/'), [
+ 'money' => $money,
+ 'nested' => ['money' => $money],
+ 'wrapped' => (object) ['money' => $money],
+ 'collection' => collect(['id' => 900719925474099988]),
+ ], preserveBigIntegers: true);
+
+ $marker = ['$bigint' => '900719925474099988'];
+
+ $this->assertSame($marker, $page['props']['money']->cents);
+ $this->assertSame($marker, $page['props']['nested']['money']->cents);
+ $this->assertSame($marker, $page['props']['wrapped']->money->cents);
+ $this->assertSame($marker, $page['props']['collection']['id']);
+ }
+
+ public function testSelfReferencingObjectsDoNotRecurseForever(): void
+ {
+ $cyclic = new stdClass;
+ $cyclic->id = 900719925474099988;
+ $cyclic->self = $cyclic;
+
+ // The root view throws when json_encode rejects the cycle left in the page.
+ try {
+ $this->makePage(Request::create('/'), ['cyclic' => $cyclic], preserveBigIntegers: true);
+ $this->fail('Expected json_encode to reject the self-reference.');
+ } catch (ViewException $exception) {
+ $this->assertStringContainsString('Recursion detected', $exception->getMessage());
+ }
+
+ $this->assertSame(['$bigint' => '900719925474099988'], InertiaState::current()->page['props']['cyclic']->id);
+ }
+
+ public function testAnObjectSharedByTwoPropsIsWrappedInBoth(): void
+ {
+ $shared = (object) ['id' => 900719925474099988];
+
+ $page = $this->makePage(Request::create('/'), ['first' => $shared, 'second' => $shared], preserveBigIntegers: true);
+
+ $marker = ['$bigint' => '900719925474099988'];
+
+ $this->assertSame($marker, $page['props']['first']->id);
+ $this->assertSame($marker, $page['props']['second']->id);
+ }
+
+ public function testInternalObjectsKeepTheShapeJsonEncodeGivesThem(): void
+ {
+ $page = $this->makePage(Request::create('/'), [
+ 'when' => ['at' => new DateTimeImmutable('2020-01-01T00:00:00Z')],
+ 'extended' => new class('2020-01-01T00:00:00Z') extends DateTimeImmutable {},
+ 'bag' => new class(['name' => 'John']) extends ArrayObject {},
+ ], preserveBigIntegers: true);
+
+ // Walking its properties would flatten it to an empty object.
+ $this->assertSame(
+ '{"at":{"date":"2020-01-01 00:00:00.000000","timezone_type":2,"timezone":"Z"}}',
+ json_encode($page['props']['when'])
+ );
+ $this->assertSame(
+ '{"date":"2020-01-01 00:00:00.000000","timezone_type":2,"timezone":"Z"}',
+ json_encode($page['props']['extended'])
+ );
+ $this->assertSame('{"name":"John"}', json_encode($page['props']['bag']));
+ }
+
+ public function testPureEnumsAreLeftForJsonEncodeToReject(): void
+ {
+ // Walking its properties would invent a JSON representation it does not have.
+ $this->expectException(ViewException::class);
+ $this->expectExceptionMessage('Non-backed enums have no default serialization');
+
+ $this->makePage(Request::create('/'), ['status' => UnitEnum::Index], preserveBigIntegers: true);
+ }
+
+ public function testBigIntegersInsideNumericallyKeyedObjectsAreWrappedWithoutChangingTheShape(): void
+ {
+ $page = $this->makePage(Request::create('/'), [
+ 'list' => (object) ['0' => 900719925474099988, '1' => -900719925474099988],
+ 'empty' => new stdClass,
+ ], preserveBigIntegers: true);
+
+ $this->assertInstanceOf(stdClass::class, $page['props']['list']);
+ $this->assertSame(
+ '{"0":{"$bigint":"900719925474099988"},"1":{"$bigint":"-900719925474099988"}}',
+ json_encode($page['props']['list'])
+ );
+ $this->assertSame('{}', json_encode($page['props']['empty']));
+ }
+
+ public function testBigIntegersAreWrappedWhenEnabled(): void
+ {
+ $page = $this->makePage(Request::create('/'), [
+ 'safe' => 42,
+ 'boundary' => 9007199254740991,
+ 'negativeBoundary' => -9007199254740991,
+ 'big' => 900719925474099988,
+ 'negative' => -900719925474099988,
+ 'nested' => ['deep' => [900719925474099988, 2]],
+ ], preserveBigIntegers: true);
+
+ $this->assertSame(42, $page['props']['safe']);
+ $this->assertSame(9007199254740991, $page['props']['boundary']);
+ $this->assertSame(-9007199254740991, $page['props']['negativeBoundary']);
+ $this->assertSame(['$bigint' => '900719925474099988'], $page['props']['big']);
+ $this->assertSame(['$bigint' => '-900719925474099988'], $page['props']['negative']);
+ $this->assertSame(['$bigint' => '900719925474099988'], $page['props']['nested']['deep'][0]);
+ $this->assertSame(2, $page['props']['nested']['deep'][1]);
+ }
+
+ public function testBigIntegersAreNotWrappedWhenDisabled(): void
+ {
+ $page = $this->makePage(Request::create('/'), [
+ 'big' => 900719925474099988,
+ ]);
+
+ $this->assertSame(900719925474099988, $page['props']['big']);
+ }
+
/**
* Resolve the given props through the Inertia response and return the page data.
*
* @param array $props
* @return array
*/
- protected function makePage(Request $request, array $props): array
+ protected function makePage(Request $request, array $props, bool $preserveBigIntegers = false): array
{
- $response = new Response('TestComponent', [], $props, 'app', '123');
+ $response = new Response('TestComponent', [], $props, 'app', '123', preserveBigIntegers: $preserveBigIntegers);
$response = $response->toResponse($request);
if ($response instanceof JsonResponse) {
diff --git a/tests/Inertia/ResponseFactoryTest.php b/tests/Inertia/ResponseFactoryTest.php
index 8441787d84..232b2c229f 100644
--- a/tests/Inertia/ResponseFactoryTest.php
+++ b/tests/Inertia/ResponseFactoryTest.php
@@ -17,6 +17,7 @@
use Hypervel\Inertia\MergeProp;
use Hypervel\Inertia\OnceProp;
use Hypervel\Inertia\OptionalProp;
+use Hypervel\Inertia\Response as InertiaResponse;
use Hypervel\Inertia\ResponseFactory;
use Hypervel\Inertia\ScrollMetadata;
use Hypervel\Inertia\ScrollProp;
@@ -559,7 +560,7 @@ public function testWillNotThrowExceptionIfComponentDoesNotExistWhenEnsuringIsDi
config()->set('inertia.pages.ensure_pages_exist', false);
$response = (new ResponseFactory)->render('foo');
- $this->assertInstanceOf(\Hypervel\Inertia\Response::class, $response);
+ $this->assertInstanceOf(InertiaResponse::class, $response);
}
public function testWillNotThrowExceptionIfPageExistenceSettingIsOmitted(): void
@@ -571,7 +572,7 @@ public function testWillNotThrowExceptionIfPageExistenceSettingIsOmitted(): void
$response = (new ResponseFactory)->render('foo');
- $this->assertInstanceOf(\Hypervel\Inertia\Response::class, $response);
+ $this->assertInstanceOf(InertiaResponse::class, $response);
}
public function testCanResolveComponentNameBeforeRendering(): void
@@ -625,7 +626,7 @@ public function testResolvedComponentNameIsUsedForPageExistenceChecks(): void
public function testRenderAcceptsBackedEnum(): void
{
$response = (new ResponseFactory)->render(StringBackedEnum::UsersIndex);
- $this->assertInstanceOf(\Hypervel\Inertia\Response::class, $response);
+ $this->assertInstanceOf(InertiaResponse::class, $response);
/** @phpstan-ignore-next-line */
$getComponent = fn () => $this->component;
@@ -635,7 +636,7 @@ public function testRenderAcceptsBackedEnum(): void
public function testRenderAcceptsUnitEnum(): void
{
$response = (new ResponseFactory)->render(UnitEnum::Index);
- $this->assertInstanceOf(\Hypervel\Inertia\Response::class, $response);
+ $this->assertInstanceOf(InertiaResponse::class, $response);
/** @phpstan-ignore-next-line */
$getComponent = fn () => $this->component;
@@ -786,6 +787,93 @@ public function testRenderWithFlashIncludesFlashInPage(): void
$this->assertNull(session('inertia.flash_data'));
}
+ public function testBigIntegersInFlashDataAreWrappedWhenEnabled(): void
+ {
+ config(['inertia.preserve_big_integers' => true]);
+
+ Route::middleware([StartSession::class, ExampleMiddleware::class])->post('/flash-big-integer', function (): InertiaResponse {
+ return Inertia::flash('id', 900719925474099988)
+ ->flash('safe', 42)
+ ->render('User/Edit');
+ });
+
+ $response = $this->post('/flash-big-integer', [], ['X-Inertia' => 'true']);
+
+ $response->assertSuccessful();
+ $response->assertJson([
+ 'flash' => [
+ 'id' => ['$bigint' => '900719925474099988'],
+ 'safe' => 42,
+ ],
+ ]);
+ }
+
+ public function testBigIntegersInFlashDataAreUntouchedWhenDisabled(): void
+ {
+ config(['inertia.preserve_big_integers' => false]);
+
+ Route::middleware([StartSession::class, ExampleMiddleware::class])->post('/flash-big-integer', function (): InertiaResponse {
+ return Inertia::flash('id', 900719925474099988)->render('User/Edit');
+ });
+
+ $response = $this->post('/flash-big-integer', [], ['X-Inertia' => 'true']);
+
+ $response->assertSuccessful();
+ $response->assertJson(['flash' => ['id' => 900719925474099988]]);
+ }
+
+ public function testBigIntegersCanBePreservedForASingleResponse(): void
+ {
+ config(['inertia.preserve_big_integers' => false]);
+
+ Route::middleware([StartSession::class, ExampleMiddleware::class])->get('/one-page', function (): InertiaResponse {
+ return Inertia::render('User/Edit', ['id' => 900719925474099988])->preserveBigIntegers();
+ });
+
+ $response = $this->get('/one-page', ['X-Inertia' => 'true']);
+
+ $response->assertJson([
+ 'props' => ['id' => ['$bigint' => '900719925474099988']],
+ 'preserveBigIntegers' => true,
+ ]);
+ }
+
+ public function testASingleResponseCanOptOutOfPreservingBigIntegers(): void
+ {
+ config(['inertia.preserve_big_integers' => true]);
+
+ Route::middleware([StartSession::class, ExampleMiddleware::class])->get('/opted-out', function (): InertiaResponse {
+ return Inertia::render('User/Edit', ['id' => 900719925474099988])->preserveBigIntegers(false);
+ });
+
+ $response = $this->get('/opted-out', ['X-Inertia' => 'true']);
+
+ $response->assertJson(['props' => ['id' => 900719925474099988]]);
+ $response->assertJsonMissingPath('preserveBigIntegers');
+ }
+
+ public function testTheInitialPageTellsTheClientWhenBigIntegersMayBeWrapped(): void
+ {
+ config(['inertia.preserve_big_integers' => true]);
+
+ Route::middleware([StartSession::class, ExampleMiddleware::class])->get('/root', function (): InertiaResponse {
+ return Inertia::render('User/Edit', ['id' => 900719925474099988]);
+ });
+
+ $this->get('/root')->assertSee('"preserveBigIntegers":true', false);
+ }
+
+ public function testTheInitialPageIsUnmarkedWhenBigIntegersAreDisabled(): void
+ {
+ config(['inertia.preserve_big_integers' => false]);
+
+ Route::middleware([StartSession::class, ExampleMiddleware::class])->get('/root', function (): InertiaResponse {
+ return Inertia::render('User/Edit', ['id' => 900719925474099988]);
+ });
+
+ $this->get('/root')->assertDontSee('preserveBigIntegers', false);
+ }
+
public function testRenderWithoutFlashDoesNotIncludeFlashKey(): void
{
Route::middleware([StartSession::class, ExampleMiddleware::class])->get('/no-flash', function () {
From b3190935ca495cbdeb6a2d960999718b8f53520d Mon Sep 17 00:00:00 2001
From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com>
Date: Sat, 3 Oct 2026 14:36:57 +0000
Subject: [PATCH 02/18] Add the --graceful option to inertia:stop-ssr
Ports inertiajs/inertia-laravel #917. inertia:stop-ssr fails when the
SSR server is not running, which breaks deployment scripts that stop
the server before it has ever started. With --graceful, the command
reports that the server is not running and exits successfully instead.
Only an unreachable server counts as not running. When something
answers on the SSR URL with an unhealthy response, the command still
fails, with or without --graceful, as upstream's tests require.
Hypervel adaptation: the command already checks health before shutting
the server down, because the HTTP client cannot tell a refused
connection from the official SSR server closing the connection without
replying to /shutdown. A new HttpGateway::checkHealth() returns the
health result and throws ConnectionException when the server cannot be
reached, so the command can tell an unreachable server from an
unhealthy response. isHealthy() wraps it and still returns false for
both.
vite.md documents stopping the SSR server and the --graceful option,
adapted from inertiajs/docs cf513d8ffc.
Upstream reference: inertiajs/inertia-laravel 3.x at 4da52b72da.
Validation: upstream tests ported to StopSsrTest, plus the Inertia
suite and PHPStan.
---
src/docs/vite.md | 6 ++++
src/inertia/src/Commands/StopSsr.php | 18 ++++++++--
src/inertia/src/Ssr/HttpGateway.php | 17 +++++++--
tests/Inertia/Commands/StopSsrTest.php | 48 ++++++++++++++++++++++++--
4 files changed, 83 insertions(+), 6 deletions(-)
diff --git a/src/docs/vite.md b/src/docs/vite.md
index 9389998fb5..fa04757e0f 100644
--- a/src/docs/vite.md
+++ b/src/docs/vite.md
@@ -891,6 +891,12 @@ php artisan inertia:start-ssr --runtime=bun
You may also configure the runtime using the `INERTIA_SSR_RUNTIME` environment variable. Runtime values may be executable names or absolute paths.
+To stop the SSR server, such as when deploying a new version of your application, you may use the `inertia:stop-ssr` command. Your process monitor, such as Supervisor, is responsible for restarting the SSR server after it stops. By default, the command fails when the SSR server is not running. You may pass the `--graceful` option to return a successful exit code instead, which is useful in deployment scripts that may run before the SSR server has started:
+
+```shell
+php artisan inertia:stop-ssr --graceful
+```
+
The `hot_url` option within your application's `inertia.ssr` configuration may be used to specify the SSR server URL while Vite is running. This option may also be configured using the `INERTIA_SSR_HOT_URL` environment variable. The `connect_timeout` and `timeout` options control how many seconds Hypervel waits for the SSR server; you may set either option to `null` to use the HTTP client's global timeout instead. The `backoff` option determines how long a worker skips SSR after a connection failure or malformed response.
For more control, you may use the `Inertia::configureSsrRequestUsing` method, typically from a service provider. The closure receives the `PendingRequest` before it is sent, so you may add retries, headers, or any other option supported by Hypervel's [HTTP client](/docs/{{version}}/http-client):
diff --git a/src/inertia/src/Commands/StopSsr.php b/src/inertia/src/Commands/StopSsr.php
index c53cd80e3a..fb88f0895b 100644
--- a/src/inertia/src/Commands/StopSsr.php
+++ b/src/inertia/src/Commands/StopSsr.php
@@ -15,7 +15,7 @@ class StopSsr extends Command
/**
* The console command name.
*/
- protected ?string $signature = 'inertia:stop-ssr';
+ protected ?string $signature = 'inertia:stop-ssr {--graceful : Return a successful exit code when the SSR server is not running}';
/**
* The console command description.
@@ -27,7 +27,21 @@ class StopSsr extends Command
*/
public function handle(HttpGateway $gateway): int
{
- if (! $gateway->isHealthy()) {
+ try {
+ $healthy = $gateway->checkHealth();
+ } catch (ConnectionException) {
+ // Only an unreachable server counts as not running. An unhealthy
+ // response still means a server answered on the SSR URL.
+ if ($this->option('graceful')) {
+ $this->comment('Inertia SSR server is not running.');
+
+ return self::SUCCESS;
+ }
+
+ $healthy = false;
+ }
+
+ if (! $healthy) {
$this->error('Unable to connect to Inertia SSR server.');
return self::FAILURE;
diff --git a/src/inertia/src/Ssr/HttpGateway.php b/src/inertia/src/Ssr/HttpGateway.php
index 690c431ecf..e35108fb5e 100644
--- a/src/inertia/src/Ssr/HttpGateway.php
+++ b/src/inertia/src/Ssr/HttpGateway.php
@@ -8,7 +8,6 @@
use Hypervel\Contracts\Events\Dispatcher;
use Hypervel\Foundation\Http\Middleware\Concerns\ExcludesPaths;
use Hypervel\Http\Client\ConnectionException;
-use Hypervel\Http\Client\HttpClientException;
use Hypervel\Http\Client\PendingRequest;
use Hypervel\Http\Client\RequestException;
use Hypervel\Http\Request;
@@ -240,12 +239,26 @@ protected function ssrIsEnabled(Request $request): bool
* Determine if the SSR server is healthy.
*/
public function isHealthy(): bool
+ {
+ try {
+ return $this->checkHealth();
+ } catch (ConnectionException) {
+ return false;
+ }
+ }
+
+ /**
+ * Determine if the SSR server is healthy, throwing when it cannot be reached.
+ *
+ * @throws ConnectionException
+ */
+ public function checkHealth(): bool
{
$pendingRequest = $this->pendingRequest();
try {
return $pendingRequest->get($this->getProductionUrl('/health'))->successful();
- } catch (HttpClientException) {
+ } catch (RequestException) {
return false;
}
}
diff --git a/tests/Inertia/Commands/StopSsrTest.php b/tests/Inertia/Commands/StopSsrTest.php
index c9a5ff7516..3ff12b6dba 100644
--- a/tests/Inertia/Commands/StopSsrTest.php
+++ b/tests/Inertia/Commands/StopSsrTest.php
@@ -26,10 +26,32 @@ protected function setUp(): void
Http::preventStrayRequests();
}
- public function testFailsWhenTheSsrServerIsUnhealthy(): void
+ public function testFailureWhenTheSsrServerIsNotRunning(): void
{
Http::fake([
- $this->healthUrl => Http::response(status: 500),
+ $this->healthUrl => Http::failedConnection('Connection refused'),
+ ]);
+
+ $this->artisan('inertia:stop-ssr')
+ ->expectsOutput('Unable to connect to Inertia SSR server.')
+ ->assertExitCode(1);
+ }
+
+ public function testSuccessWhenTheSsrServerIsNotRunningAndTheGracefulOptionIsUsed(): void
+ {
+ Http::fake([
+ $this->healthUrl => Http::failedConnection('Connection refused'),
+ ]);
+
+ $this->artisan('inertia:stop-ssr', ['--graceful' => true])
+ ->expectsOutput('Inertia SSR server is not running.')
+ ->assertExitCode(0);
+ }
+
+ public function testFailureWhenAnotherServiceRespondsOnTheSsrUrl(): void
+ {
+ Http::fake([
+ $this->healthUrl => Http::response('Hello from another service', 404),
]);
$this->artisan('inertia:stop-ssr')
@@ -39,6 +61,28 @@ public function testFailsWhenTheSsrServerIsUnhealthy(): void
Http::assertSentCount(1);
}
+ public function testFailureWhenAnotherServiceRespondsOnTheSsrUrlAndTheGracefulOptionIsUsed(): void
+ {
+ Http::fake([
+ $this->healthUrl => Http::response('Hello from another service', 404),
+ ]);
+
+ $this->artisan('inertia:stop-ssr', ['--graceful' => true])
+ ->expectsOutput('Unable to connect to Inertia SSR server.')
+ ->assertExitCode(1);
+ }
+
+ public function testTheResponseBodyIsNotPrintedToTheConsole(): void
+ {
+ Http::fake([
+ $this->healthUrl => Http::response('Hello from another service', 404),
+ ]);
+
+ $this->expectOutputString('');
+
+ $this->artisan('inertia:stop-ssr')->run();
+ }
+
public function testSucceedsWhenTheSsrServerStops(): void
{
Http::fake([
From 3986dc041a0fe506ae912c00214e3058e868d8dc Mon Sep 17 00:00:00 2001
From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com>
Date: Sat, 3 Oct 2026 14:37:05 +0000
Subject: [PATCH 03/18] Assert preserved big integers as plain integers in
Inertia tests
Ports inertiajs/inertia-laravel #918. When a page preserves big
integers, its props and flash data carry {"$bigint": "..."} markers, so
assertInertia() and inertiaProps() saw the markers rather than the
integers passed to the response. AssertableInertia now turns the
markers back into integers when the page sets preserveBigIntegers, so
tests assert against the original values. Pages without the flag keep
any marker-shaped data the application sends itself.
Upstream reference: inertiajs/inertia-laravel 3.x at 4da52b72da.
Validation: upstream tests ported to AssertableInertiaTest, plus the
Inertia suite and PHPStan.
---
src/inertia/src/Testing/AssertableInertia.php | 27 ++++++++++++++++++
.../Inertia/Testing/AssertableInertiaTest.php | 28 +++++++++++++++++++
2 files changed, 55 insertions(+)
diff --git a/src/inertia/src/Testing/AssertableInertia.php b/src/inertia/src/Testing/AssertableInertia.php
index 07edb85b3c..8cfee0b347 100644
--- a/src/inertia/src/Testing/AssertableInertia.php
+++ b/src/inertia/src/Testing/AssertableInertia.php
@@ -80,6 +80,11 @@ public static function fromTestResponse(TestResponse $response): self
PHPUnit::fail('Not a valid Inertia response.');
}
+ if (($page['preserveBigIntegers'] ?? false) === true) {
+ $page['props'] = static::decodeBigIntegers($page['props']);
+ $page['flash'] = static::decodeBigIntegers($page['flash'] ?? []);
+ }
+
$instance = static::fromArray($page['props']);
$instance->component = $page['component'];
$instance->url = $page['url'];
@@ -92,6 +97,28 @@ public static function fromTestResponse(TestResponse $response): self
return $instance;
}
+ /**
+ * Turn big integer markers back into integers, so assertions are made
+ * against the values that were passed to the response.
+ *
+ * @param array $value
+ * @return array
+ */
+ protected static function decodeBigIntegers(array $value): array
+ {
+ foreach ($value as $key => $nested) {
+ if (! is_array($nested)) {
+ continue;
+ }
+
+ $value[$key] = is_string($nested['$bigint'] ?? null)
+ ? (int) $nested['$bigint']
+ : static::decodeBigIntegers($nested);
+ }
+
+ return $value;
+ }
+
/**
* Assert that the page uses the given component.
*
diff --git a/tests/Inertia/Testing/AssertableInertiaTest.php b/tests/Inertia/Testing/AssertableInertiaTest.php
index 936fa00a39..b12eae937c 100644
--- a/tests/Inertia/Testing/AssertableInertiaTest.php
+++ b/tests/Inertia/Testing/AssertableInertiaTest.php
@@ -6,6 +6,7 @@
use Hypervel\Inertia\Inertia;
use Hypervel\Inertia\Middleware;
+use Hypervel\Inertia\Response;
use Hypervel\Inertia\Support\Header;
use Hypervel\Inertia\Testing\AssertableInertia;
use Hypervel\Session\Middleware\StartSession;
@@ -516,6 +517,33 @@ public function testTheFlashDataCanBeAsserted(): void
});
}
+ public function testBigIntegersAreAssertedAsIntegers(): void
+ {
+ $response = $this->makeMockRequest(
+ fn (): Response => Inertia::render('foo', [
+ 'order' => ['id' => 900719925474099988, 'lines' => [['reference' => -900719925474099988]]],
+ ])->flash('id', 900719925474099988)->preserveBigIntegers(),
+ StartSession::class
+ );
+
+ $response->assertInertia(fn (AssertableInertia $inertia): AssertableInertia => $inertia
+ ->where('order.id', 900719925474099988)
+ ->where('order.lines.0.reference', -900719925474099988)
+ ->hasFlash('id', 900719925474099988));
+
+ $this->assertSame(900719925474099988, $response->inertiaProps('order.id'));
+ }
+
+ public function testBigIntegerMarkersBuiltByTheAppAreLeftAloneWithoutPreservingBigIntegers(): void
+ {
+ $response = $this->makeMockRequest(
+ Inertia::render('foo', ['id' => ['$bigint' => '900719925474099988']])
+ );
+
+ $response->assertInertia(fn (AssertableInertia $inertia): AssertableInertia => $inertia
+ ->where('id', ['$bigint' => '900719925474099988']));
+ }
+
public function testTheFlashAssertionFailsWhenKeyIsMissing(): void
{
$response = $this->makeMockRequest(Inertia::render('foo'));
From 2619a315b12d949858bd73f343beadff1dc0225a Mon Sep 17 00:00:00 2001
From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com>
Date: Sat, 3 Oct 2026 19:59:21 +0000
Subject: [PATCH 04/18] Title the Inertia middleware test endpoint helper
Give prepareMockEndpoint() the required method title, describing the
given or example middleware it registers.
---
tests/Inertia/MiddlewareTest.php | 2 ++
1 file changed, 2 insertions(+)
diff --git a/tests/Inertia/MiddlewareTest.php b/tests/Inertia/MiddlewareTest.php
index db06f4904b..f13be4b45c 100644
--- a/tests/Inertia/MiddlewareTest.php
+++ b/tests/Inertia/MiddlewareTest.php
@@ -961,6 +961,8 @@ public function testVersionCacheResetsAfterFlushState(): void
}
/**
+ * Register an Inertia endpoint handled by the given or an example middleware.
+ *
* @param array $shared
*/
private function prepareMockEndpoint(int|string|null $version = null, array $shared = [], ?Middleware $middleware = null): RouteInstance
From f6a76559935eafa63f41d814f977840a84840358 Mon Sep 17 00:00:00 2001
From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com>
Date: Sun, 4 Oct 2026 09:51:49 +0000
Subject: [PATCH 05/18] Track sweeps for test method titles and return types
AGENTS.md requires a title docblock on every method except test* methods
and a native return type on test methods, but most older tests omit them.
Agents follow the surrounding code over the written rule, so new tests
keep reproducing the gap until review catches it. Record the owner's
request for one sweep of each, about 8,400 untitled methods in 1,600
files and 5,600 untyped test methods in 490 files, followed by an
automated check that keeps the code and the rule aligned.
---
docs/todo.md | 2 ++
1 file changed, 2 insertions(+)
diff --git a/docs/todo.md b/docs/todo.md
index 5ad53efe51..394f28acdc 100644
--- a/docs/todo.md
+++ b/docs/todo.md
@@ -30,6 +30,8 @@
## Testing
- Replace PHPUnit 13's [soft-deprecated `expectExceptionMessage()`](https://github.com/sebastianbergmann/phpunit/issues/6560) calls across the test suite. Preserve intended matching semantics: use `expectExceptionObject()` for combined class/message/code expectations, `expectExceptionMessageIs()` for exact messages, and `expectExceptionMessageIsOrContains()` for substring matching. Audit each assertion's intent and run its owning test file as it is changed.
+- Add the required title docblocks to the untitled non-test methods in `tests/`: about 8,400 methods in 1,600 files, mostly helpers, fixtures, and methods of anonymous classes in older tests. `AGENTS.md` requires a title on every method except `test*` methods, but most existing tests omit them, and agents follow the surrounding code over the written rule, so new tests keep reproducing the gap until review catches it. Make the change as one approved sweep (short imperative titles only), then add an automated check, such as a PHPStan rule, so the code and the rule cannot drift apart again. Decide whether the check also covers `src/`.
+- Add native return types to the `test*` methods that lack them: about 5,600 methods in 490 files. `AGENTS.md` requires `: void` on test methods; a test that returns a value for `#[Depends]` takes its actual return type instead. As with method titles, untyped methods in existing files lead new tests to copy them, so make the change as one approved sweep and enforce it with the same automated check.
- Lift the PHPUnit `13.3.*` pin in the root, Testbench and dogfood package manifests, and in the `hypervel/hypervel` application skeleton, once ParaTest supports PHPUnit 13.4. PHPUnit 13.4.0 made the internal `PhpHandler` constructor require an event emitter, and ParaTest 7.25.0 and Hypervel's `RunsInParallel` still construct it without one, so parallel runs fail before any test starts. When lifting the pin, pass the emitter to `PhpHandler` in `RunsInParallel`, as PHPUnit 13.4's own `Application` does.
## Filesystem
From 582a2abc4a92d559e7f08bd04e7f4a1410f32db7 Mon Sep 17 00:00:00 2001
From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com>
Date: Thu, 8 Oct 2026 13:15:03 +0000
Subject: [PATCH 06/18] Replace deprecated exception message expectations
PHPUnit deprecates expectExceptionMessage(), which matches any
substring. Replace the eight remaining calls with the explicit
alternatives, keeping what each test meant to check.
Use expectExceptionMessageIs() for the notification serialization and
streaming timeout messages, which the tests expect in full. Keep partial
matching with expectExceptionMessageIsOrContains() where the expected
text is part of a longer message: the Inertia view wrapper, the two
database read pool messages and the Curl host fragments.
Validation: each changed test file passes on its own.
---
tests/Database/DatabaseReadPoolTest.php | 4 ++--
tests/Http/Client/CurlStreamingHandlerTest.php | 2 +-
tests/Http/HttpClientStreamingTest.php | 4 ++--
tests/Inertia/PropsResolverTest.php | 2 +-
.../Notifications/NotificationTransportSerializationTest.php | 4 ++--
5 files changed, 8 insertions(+), 8 deletions(-)
diff --git a/tests/Database/DatabaseReadPoolTest.php b/tests/Database/DatabaseReadPoolTest.php
index 5b9aafd3d3..524e786224 100644
--- a/tests/Database/DatabaseReadPoolTest.php
+++ b/tests/Database/DatabaseReadPoolTest.php
@@ -69,7 +69,7 @@ public function testReadRecordsRequireTheSameEffectivePoolOptions(array $secondO
if ($conflicting) {
$this->expectException(InvalidArgumentException::class);
- $this->expectExceptionMessage('must use the same effective pool options');
+ $this->expectExceptionMessageIsOrContains('must use the same effective pool options');
}
$pool = new DatabasePool($this->app, 'read_pool_options::read');
@@ -110,7 +110,7 @@ public function testEveryReadRecordIsCheckedForAnInMemorySqliteDatabase(): void
]]);
$this->app->instance('db.factory', new ReadPoolConnectionFactory($this->app));
$this->expectException(InvalidArgumentException::class);
- $this->expectExceptionMessage('cannot use a derived read pool for in-memory SQLite');
+ $this->expectExceptionMessageIsOrContains('cannot use a derived read pool for in-memory SQLite');
new DatabasePool($this->app, 'read_pool_memory::read');
}
diff --git a/tests/Http/Client/CurlStreamingHandlerTest.php b/tests/Http/Client/CurlStreamingHandlerTest.php
index bf5068990e..1f980f8bd5 100644
--- a/tests/Http/Client/CurlStreamingHandlerTest.php
+++ b/tests/Http/Client/CurlStreamingHandlerTest.php
@@ -37,7 +37,7 @@ class CurlStreamingHandlerTest extends TestCase
public function testAmbiguousHostsAreRejectedBeforeConnecting(string $url, array $headers, string $message): void
{
$this->expectException(ConnectionException::class);
- $this->expectExceptionMessage($message);
+ $this->expectExceptionMessageIsOrContains($message);
(new Factory)->withOptions(['stream' => true])->withHeaders($headers)->get($url);
}
diff --git a/tests/Http/HttpClientStreamingTest.php b/tests/Http/HttpClientStreamingTest.php
index 73ac8ff6f6..2c5f35273f 100644
--- a/tests/Http/HttpClientStreamingTest.php
+++ b/tests/Http/HttpClientStreamingTest.php
@@ -475,7 +475,7 @@ public function testSilentHeadersUseTheIdleTimeoutWhenTheTotalTimeoutIsDisabled(
$this->withStreamingServer('silent-headers', function (string $address): void {
try {
$this->expectException(ConnectionException::class);
- $this->expectExceptionMessage('The streaming request timed out before response headers.');
+ $this->expectExceptionMessageIs('The streaming request timed out before response headers.');
(new Factory)->timeout(0)->withOptions(['stream' => true, 'read_timeout' => 0.2])->get('http://' . $address);
} finally {
@@ -502,7 +502,7 @@ public function testHeaderProgressDoesNotResetTheTotalHeaderDeadline(): void
{
$this->withStreamingServer('trickle-headers', function (string $address): void {
$this->expectException(ConnectionException::class);
- $this->expectExceptionMessage('The streaming request timed out before response headers.');
+ $this->expectExceptionMessageIs('The streaming request timed out before response headers.');
(new Factory)->timeout(0.4)->withOptions(['stream' => true, 'read_timeout' => 0.4])->get('http://' . $address);
});
diff --git a/tests/Inertia/PropsResolverTest.php b/tests/Inertia/PropsResolverTest.php
index 411824f071..4e6eb5adb6 100644
--- a/tests/Inertia/PropsResolverTest.php
+++ b/tests/Inertia/PropsResolverTest.php
@@ -1519,7 +1519,7 @@ public function testPureEnumsAreLeftForJsonEncodeToReject(): void
{
// Walking its properties would invent a JSON representation it does not have.
$this->expectException(ViewException::class);
- $this->expectExceptionMessage('Non-backed enums have no default serialization');
+ $this->expectExceptionMessageIsOrContains('Non-backed enums have no default serialization');
$this->makePage(Request::create('/'), ['status' => UnitEnum::Index], preserveBigIntegers: true);
}
diff --git a/tests/Notifications/NotificationTransportSerializationTest.php b/tests/Notifications/NotificationTransportSerializationTest.php
index 6111866896..2dc0b235d8 100644
--- a/tests/Notifications/NotificationTransportSerializationTest.php
+++ b/tests/Notifications/NotificationTransportSerializationTest.php
@@ -318,7 +318,7 @@ public function testClosedAndDetachedStreamsRetainTheirCapabilityFlags(string $o
$this->assertSame([], $restored->getMetadata());
$this->assertNull($restored->getSize());
$this->expectException(RuntimeException::class);
- $this->expectExceptionMessage('Stream is detached');
+ $this->expectExceptionMessageIs('Stream is detached');
$restored->getContents();
} finally {
if (is_resource($resource)) {
@@ -364,7 +364,7 @@ public function testUnsupportedBufferOriginsAndCapabilitiesAreRejectedWithoutCon
try {
$this->expectException(LogicException::class);
- $this->expectExceptionMessage('Cannot serialize notification transport bodies outside writable memory buffers.');
+ $this->expectExceptionMessageIs('Cannot serialize notification transport bodies outside writable memory buffers.');
serialize($event);
} finally {
$this->assertSame($position, $stream->tell());
From 46cad23d69d1beffcdb739616cab9876d2c92470 Mon Sep 17 00:00:00 2001
From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com>
Date: Thu, 8 Oct 2026 13:15:14 +0000
Subject: [PATCH 07/18] Reconcile Slack Block Kit with upstream
Compare the Block Kit contracts, blocks, composites, elements and
default ID generation with laravel/slack-notification-channel 3.x at
7b7c3e220a4f7a45cd88d65069f28d4fb4b67281, together with their unit
tests.
Every remaining source difference is a deliberate Hypervel fix or
adaptation: static return types, Slack limits counted in characters,
filters that keep '0' values, UTF-8-safe truncation, verbatim select
option values, the select placeholder and option count limits, select
action IDs seeded from their text, and generated IDs capped at 255
characters.
Correct inherited wording: imperative titles for TextObject::markdown()
and ConfirmObject::danger(), the PlainTextOnlyTextObject::$text
docblock copied from $type, and the SectionBlock error, which now asks
for at least one field rather than one block. Type the remaining
callbacks.
Every upstream unit test case and assertion is present. Rename the
upstream-derived test methods to the camelCase form of their upstream
names so later syncs can match them, which also fixes a header text
length test that was named after the block ID. Remove two
UsersSelectElementTest cases already covered by StaticSelectElementTest,
and add titles to SelectOptionTest's data providers.
Validation: the Slack suite (208 tests), the Horizon LongWaitDetected
test, composer lint:fix and composer analyse pass.
---
.../Slack/BlockKit/Blocks/ActionsBlock.php | 8 ++---
.../Slack/BlockKit/Blocks/ContextBlock.php | 6 ++--
.../Slack/BlockKit/Blocks/SectionBlock.php | 4 +--
.../BlockKit/Composites/ConfirmObject.php | 2 +-
.../Composites/PlainTextOnlyTextObject.php | 2 +-
.../Slack/BlockKit/Composites/TextObject.php | 2 +-
.../Elements/Selects/SelectElement.php | 2 +-
.../Elements/Selects/StaticSelectElement.php | 4 +--
.../Elements/Selects/UsersSelectElement.php | 2 +-
.../Slack/Unit/Blocks/ActionsBlockTest.php | 14 ++++----
.../Slack/Unit/Blocks/ContextBlockTest.php | 14 ++++----
.../Slack/Unit/Blocks/DividerBlockTest.php | 6 ++--
.../Slack/Unit/Blocks/HeaderBlockTest.php | 8 ++---
.../Slack/Unit/Blocks/ImageBlockTest.php | 16 ++++-----
.../Slack/Unit/Blocks/SectionBlockTest.php | 24 ++++++-------
.../Unit/Composites/ConfirmObjectTest.php | 20 +++++------
.../PlainTextOnlyTextObjectTest.php | 8 ++---
.../Slack/Unit/Composites/TextObjectTest.php | 16 ++++-----
.../Slack/Unit/Elements/ButtonElementTest.php | 34 +++++++++----------
.../Elements/Selects/SelectOptionTest.php | 9 +++++
.../Selects/UsersSelectElementTest.php | 15 --------
21 files changed, 105 insertions(+), 111 deletions(-)
diff --git a/src/slack-notification-channel/src/Slack/BlockKit/Blocks/ActionsBlock.php b/src/slack-notification-channel/src/Slack/BlockKit/Blocks/ActionsBlock.php
index 11c16f73ec..ef9206c49d 100644
--- a/src/slack-notification-channel/src/Slack/BlockKit/Blocks/ActionsBlock.php
+++ b/src/slack-notification-channel/src/Slack/BlockKit/Blocks/ActionsBlock.php
@@ -48,7 +48,7 @@ public function id(string $id): static
*/
public function button(string $text): ButtonElement
{
- return tap(new ButtonElement($text), function (ButtonElement $button) {
+ return tap(new ButtonElement($text), function (ButtonElement $button): void {
$this->elements[] = $button;
});
}
@@ -58,7 +58,7 @@ public function button(string $text): ButtonElement
*/
public function staticSelect(string $text): StaticSelectElement
{
- return tap(new StaticSelectElement($text), function (StaticSelectElement $select) {
+ return tap(new StaticSelectElement($text), function (StaticSelectElement $select): void {
$this->elements[] = $select;
});
}
@@ -68,7 +68,7 @@ public function staticSelect(string $text): StaticSelectElement
*/
public function usersSelect(string $text): UsersSelectElement
{
- return tap(new UsersSelectElement($text), function (UsersSelectElement $select) {
+ return tap(new UsersSelectElement($text), function (UsersSelectElement $select): void {
$this->elements[] = $select;
});
}
@@ -96,7 +96,7 @@ public function toArray(): array
return array_merge([
'type' => 'actions',
- 'elements' => array_map(fn (Arrayable $element) => $element->toArray(), $this->elements),
+ 'elements' => array_map(fn (Arrayable $element): array => $element->toArray(), $this->elements),
], $optionalFields);
}
}
diff --git a/src/slack-notification-channel/src/Slack/BlockKit/Blocks/ContextBlock.php b/src/slack-notification-channel/src/Slack/BlockKit/Blocks/ContextBlock.php
index 70ab10533c..146e2f45e9 100644
--- a/src/slack-notification-channel/src/Slack/BlockKit/Blocks/ContextBlock.php
+++ b/src/slack-notification-channel/src/Slack/BlockKit/Blocks/ContextBlock.php
@@ -47,7 +47,7 @@ public function id(string $id): static
*/
public function image(string $imageUrl, ?string $altText = null): ImageElement
{
- return tap(new ImageElement($imageUrl, $altText), function (ImageElement $element) {
+ return tap(new ImageElement($imageUrl, $altText), function (ImageElement $element): void {
$this->elements[] = $element;
});
}
@@ -57,7 +57,7 @@ public function image(string $imageUrl, ?string $altText = null): ImageElement
*/
public function text(string $text): TextObject
{
- return tap(new TextObject($text), function (TextObject $element) {
+ return tap(new TextObject($text), function (TextObject $element): void {
$this->elements[] = $element;
});
}
@@ -85,7 +85,7 @@ public function toArray(): array
return array_merge([
'type' => 'context',
- 'elements' => array_map(fn (Arrayable $element) => $element->toArray(), $this->elements),
+ 'elements' => array_map(fn (Arrayable $element): array => $element->toArray(), $this->elements),
], $optionalFields);
}
}
diff --git a/src/slack-notification-channel/src/Slack/BlockKit/Blocks/SectionBlock.php b/src/slack-notification-channel/src/Slack/BlockKit/Blocks/SectionBlock.php
index 9d644dafb9..4f8776eb10 100644
--- a/src/slack-notification-channel/src/Slack/BlockKit/Blocks/SectionBlock.php
+++ b/src/slack-notification-channel/src/Slack/BlockKit/Blocks/SectionBlock.php
@@ -97,7 +97,7 @@ public function toArray(): array
}
if (is_null($this->text) && empty($this->fields)) {
- throw new LogicException('A section requires at least one block, or the text to be set.');
+ throw new LogicException('A section requires at least one field, or the text to be set.');
}
if (count($this->fields) > 10) {
@@ -108,7 +108,7 @@ public function toArray(): array
'text' => $this->text?->toArray(),
'block_id' => $this->blockId,
'accessory' => $this->accessory?->toArray(),
- 'fields' => array_map(fn (Arrayable $element) => $element->toArray(), $this->fields),
+ 'fields' => array_map(fn (Arrayable $element): array => $element->toArray(), $this->fields),
], static fn (mixed $value): bool => $value !== null && $value !== '' && $value !== []);
return array_merge([
diff --git a/src/slack-notification-channel/src/Slack/BlockKit/Composites/ConfirmObject.php b/src/slack-notification-channel/src/Slack/BlockKit/Composites/ConfirmObject.php
index 68bba607cd..ccb26a2e3a 100644
--- a/src/slack-notification-channel/src/Slack/BlockKit/Composites/ConfirmObject.php
+++ b/src/slack-notification-channel/src/Slack/BlockKit/Composites/ConfirmObject.php
@@ -98,7 +98,7 @@ public function deny(string $label): PlainTextOnlyTextObject
}
/**
- * Marks the confirm dialog as dangerous.
+ * Mark the confirm dialog as dangerous.
*/
public function danger(): static
{
diff --git a/src/slack-notification-channel/src/Slack/BlockKit/Composites/PlainTextOnlyTextObject.php b/src/slack-notification-channel/src/Slack/BlockKit/Composites/PlainTextOnlyTextObject.php
index 1237fa2c48..7480da3517 100644
--- a/src/slack-notification-channel/src/Slack/BlockKit/Composites/PlainTextOnlyTextObject.php
+++ b/src/slack-notification-channel/src/Slack/BlockKit/Composites/PlainTextOnlyTextObject.php
@@ -10,7 +10,7 @@
class PlainTextOnlyTextObject implements ObjectContract
{
/**
- * The formatting to use for this text object.
+ * The text for this text object.
*/
protected string $text;
diff --git a/src/slack-notification-channel/src/Slack/BlockKit/Composites/TextObject.php b/src/slack-notification-channel/src/Slack/BlockKit/Composites/TextObject.php
index c7df48f302..095807b7f8 100644
--- a/src/slack-notification-channel/src/Slack/BlockKit/Composites/TextObject.php
+++ b/src/slack-notification-channel/src/Slack/BlockKit/Composites/TextObject.php
@@ -23,7 +23,7 @@ class TextObject extends PlainTextOnlyTextObject
protected ?bool $verbatim = null;
/**
- * Changes the formatting of this text object to mrkdwn.
+ * Change the formatting of this text object to mrkdwn.
*/
public function markdown(): static
{
diff --git a/src/slack-notification-channel/src/Slack/BlockKit/Elements/Selects/SelectElement.php b/src/slack-notification-channel/src/Slack/BlockKit/Elements/Selects/SelectElement.php
index ef6958b475..163f50aa2a 100644
--- a/src/slack-notification-channel/src/Slack/BlockKit/Elements/Selects/SelectElement.php
+++ b/src/slack-notification-channel/src/Slack/BlockKit/Elements/Selects/SelectElement.php
@@ -79,6 +79,6 @@ public function toArray(): array
'action_id' => $this->actionId,
'placeholder' => $this->placeholder?->toArray(),
'focus_on_load' => $this->focusOnLoad,
- ], static fn ($value): bool => $value !== null);
+ ], static fn (mixed $value): bool => $value !== null);
}
}
diff --git a/src/slack-notification-channel/src/Slack/BlockKit/Elements/Selects/StaticSelectElement.php b/src/slack-notification-channel/src/Slack/BlockKit/Elements/Selects/StaticSelectElement.php
index f10a568db8..6d214c5af7 100644
--- a/src/slack-notification-channel/src/Slack/BlockKit/Elements/Selects/StaticSelectElement.php
+++ b/src/slack-notification-channel/src/Slack/BlockKit/Elements/Selects/StaticSelectElement.php
@@ -73,12 +73,12 @@ public function toArray(): array
$options = array_values($this->options);
- $options = array_map(fn (SelectOption $option) => $option->toArray(), $options);
+ $options = array_map(fn (SelectOption $option): array => $option->toArray(), $options);
return array_filter(array_merge([
'type' => 'static_select',
'options' => $options,
'initial_option' => $this->initialOption?->toArray(),
- ], parent::toArray()), fn ($value): bool => $value !== null);
+ ], parent::toArray()), fn (mixed $value): bool => $value !== null);
}
}
diff --git a/src/slack-notification-channel/src/Slack/BlockKit/Elements/Selects/UsersSelectElement.php b/src/slack-notification-channel/src/Slack/BlockKit/Elements/Selects/UsersSelectElement.php
index 5c4eb00cb8..400e6b0303 100644
--- a/src/slack-notification-channel/src/Slack/BlockKit/Elements/Selects/UsersSelectElement.php
+++ b/src/slack-notification-channel/src/Slack/BlockKit/Elements/Selects/UsersSelectElement.php
@@ -41,6 +41,6 @@ public function toArray(): array
return array_filter(array_merge([
'type' => 'users_select',
'initial_user' => $this->initialUser,
- ], parent::toArray()), fn ($value): bool => $value !== null);
+ ], parent::toArray()), fn (mixed $value): bool => $value !== null);
}
}
diff --git a/tests/SlackNotificationChannel/Slack/Unit/Blocks/ActionsBlockTest.php b/tests/SlackNotificationChannel/Slack/Unit/Blocks/ActionsBlockTest.php
index 0555e5419a..d1c9c47e73 100644
--- a/tests/SlackNotificationChannel/Slack/Unit/Blocks/ActionsBlockTest.php
+++ b/tests/SlackNotificationChannel/Slack/Unit/Blocks/ActionsBlockTest.php
@@ -10,7 +10,7 @@
class ActionsBlockTest extends TestCase
{
- public function testArrayable(): void
+ public function testItIsArrayable(): void
{
$block = new ActionsBlock;
$block->button('Example Button');
@@ -30,7 +30,7 @@ public function testArrayable(): void
], $block->toArray());
}
- public function testRequiresAtLeastOneElement(): void
+ public function testItRequiresAtLeastOneElement(): void
{
$this->expectException(LogicException::class);
$this->expectExceptionMessageIs('There must be at least one element in each actions block.');
@@ -39,7 +39,7 @@ public function testRequiresAtLeastOneElement(): void
$block->toArray();
}
- public function testDoesNotAllowMoreTwentyFiveElements(): void
+ public function testItDoesNotAllowMoreThanTwentyFiveElements(): void
{
$this->expectException(LogicException::class);
$this->expectExceptionMessageIs('There is a maximum of 25 elements in each actions block.');
@@ -52,7 +52,7 @@ public function testDoesNotAllowMoreTwentyFiveElements(): void
$block->toArray();
}
- public function testCanManuallySpecifyBlockIdField(): void
+ public function testItCanManuallySpecifyTheBlockIdField(): void
{
$block = new ActionsBlock;
$block->button('Example Button');
@@ -83,7 +83,7 @@ public function testZeroBlockIdIsPreserved(): void
$this->assertSame('0', $block->toArray()['block_id']);
}
- public function testBlockIdCantExceedTwoFiveFiveCharacters(): void
+ public function testTheBlockIdFieldCannotExceed255Characters(): void
{
$this->expectException(LogicException::class);
$this->expectExceptionMessageIs('Maximum length for the block_id field is 255 characters.');
@@ -105,7 +105,7 @@ public function testBlockIdUsesTheSlackCharacterLimit(): void
$this->assertSame($id, $block->toArray()['block_id']);
}
- public function testCanAddButtons(): void
+ public function testItCanAddButtons(): void
{
$block = new ActionsBlock;
$block->button('Example Button');
@@ -135,7 +135,7 @@ public function testCanAddButtons(): void
], $block->toArray());
}
- public function testCanAddSelects(): void
+ public function testItCanAddSelects(): void
{
$block = new ActionsBlock;
$block->staticSelect('Example Select')
diff --git a/tests/SlackNotificationChannel/Slack/Unit/Blocks/ContextBlockTest.php b/tests/SlackNotificationChannel/Slack/Unit/Blocks/ContextBlockTest.php
index 1b453a6eda..f10f2e7b72 100644
--- a/tests/SlackNotificationChannel/Slack/Unit/Blocks/ContextBlockTest.php
+++ b/tests/SlackNotificationChannel/Slack/Unit/Blocks/ContextBlockTest.php
@@ -10,7 +10,7 @@
class ContextBlockTest extends TestCase
{
- public function testArrayable(): void
+ public function testItIsArrayable(): void
{
$block = new ContextBlock;
$block->text('Location: 123 Main Street, New York, NY 10010');
@@ -26,7 +26,7 @@ public function testArrayable(): void
], $block->toArray());
}
- public function testRequiresAtLeastOneElement(): void
+ public function testItRequiresAtLeastOneElement(): void
{
$this->expectException(LogicException::class);
$this->expectExceptionMessageIs('There must be at least one element in each context block.');
@@ -35,7 +35,7 @@ public function testRequiresAtLeastOneElement(): void
$block->toArray();
}
- public function testNotAllowMoreThanTenElements(): void
+ public function testItDoesNotAllowMoreThanTenElements(): void
{
$this->expectException(LogicException::class);
$this->expectExceptionMessageIs('There is a maximum of 10 elements in each context block.');
@@ -48,7 +48,7 @@ public function testNotAllowMoreThanTenElements(): void
$block->toArray();
}
- public function testCanManuallySpecifyBlockIdField(): void
+ public function testItCanManuallySpecifyTheBlockIdField(): void
{
$block = new ContextBlock;
$block->text('Location: 123 Main Street, New York, NY 10010');
@@ -75,7 +75,7 @@ public function testZeroBlockIdIsPreserved(): void
$this->assertSame('0', $block->toArray()['block_id']);
}
- public function testBlockIdCantExceedTwoFiveFiveCharacters(): void
+ public function testTheBlockIdFieldCannotExceed255Characters(): void
{
$this->expectException(LogicException::class);
$this->expectExceptionMessageIs('Maximum length for the block_id field is 255 characters.');
@@ -97,7 +97,7 @@ public function testBlockIdUsesTheSlackCharacterLimit(): void
$this->assertSame($id, $block->toArray()['block_id']);
}
- public function testCanAddImageBlocks(): void
+ public function testItCanAddImageBlocks(): void
{
$block = new ContextBlock;
$block->image('https://image.freepik.com/free-photo/red-drawing-pin_1156-445.jpg')->alt('images');
@@ -120,7 +120,7 @@ public function testCanAddImageBlocks(): void
], $block->toArray());
}
- public function testCanAddTextBlocks(): void
+ public function testItCanAddTextBlocks(): void
{
$block = new ContextBlock;
$block->text('Location: 123 Main Street, New York, NY 10010');
diff --git a/tests/SlackNotificationChannel/Slack/Unit/Blocks/DividerBlockTest.php b/tests/SlackNotificationChannel/Slack/Unit/Blocks/DividerBlockTest.php
index 4798bf3ae4..c12d7087c9 100644
--- a/tests/SlackNotificationChannel/Slack/Unit/Blocks/DividerBlockTest.php
+++ b/tests/SlackNotificationChannel/Slack/Unit/Blocks/DividerBlockTest.php
@@ -10,7 +10,7 @@
class DividerBlockTest extends TestCase
{
- public function testArrayable(): void
+ public function testItIsArrayable(): void
{
$block = new DividerBlock;
@@ -19,7 +19,7 @@ public function testArrayable(): void
], $block->toArray());
}
- public function testCanManuallySpecifyBlockIdField(): void
+ public function testItCanManuallySpecifyTheBlockIdField(): void
{
$block = new DividerBlock;
$block->id('divider1');
@@ -35,7 +35,7 @@ public function testZeroBlockIdIsPreserved(): void
$this->assertSame('0', (new DividerBlock)->id('0')->toArray()['block_id']);
}
- public function testBlockIdCantExceedTwoFiveFiveCharacters(): void
+ public function testTheBlockIdFieldCannotExceed255Characters(): void
{
$this->expectException(LogicException::class);
$this->expectExceptionMessageIs('Maximum length for the block_id field is 255 characters.');
diff --git a/tests/SlackNotificationChannel/Slack/Unit/Blocks/HeaderBlockTest.php b/tests/SlackNotificationChannel/Slack/Unit/Blocks/HeaderBlockTest.php
index 05a1bb0401..df45194e83 100644
--- a/tests/SlackNotificationChannel/Slack/Unit/Blocks/HeaderBlockTest.php
+++ b/tests/SlackNotificationChannel/Slack/Unit/Blocks/HeaderBlockTest.php
@@ -10,7 +10,7 @@
class HeaderBlockTest extends TestCase
{
- public function testArrayable(): void
+ public function testItIsArrayable(): void
{
$block = new HeaderBlock('Budget Performance');
@@ -23,7 +23,7 @@ public function testArrayable(): void
], $block->toArray());
}
- public function testBlockIdCantExceedOneFiveZeroCharacters(): void
+ public function testTheTextHeadingCannotExceed150Characters(): void
{
$blockA = new HeaderBlock(str_repeat('a', 151));
$blockB = new HeaderBlock(str_repeat('b', 150));
@@ -45,7 +45,7 @@ public function testBlockIdCantExceedOneFiveZeroCharacters(): void
], $blockB->toArray());
}
- public function testCanManuallySpecifyBlockIdField(): void
+ public function testItCanManuallySpecifyTheBlockIdField(): void
{
$block = new HeaderBlock('Budget Performance');
$block->id('header1');
@@ -65,7 +65,7 @@ public function testZeroBlockIdIsPreserved(): void
$this->assertSame('0', (new HeaderBlock('Header'))->id('0')->toArray()['block_id']);
}
- public function testBlockIdCantExceedTwoFiveFiveCharacters(): void
+ public function testTheBlockIdFieldCannotExceed255Characters(): void
{
$this->expectException(LogicException::class);
$this->expectExceptionMessageIs('Maximum length for the block_id field is 255 characters.');
diff --git a/tests/SlackNotificationChannel/Slack/Unit/Blocks/ImageBlockTest.php b/tests/SlackNotificationChannel/Slack/Unit/Blocks/ImageBlockTest.php
index c8e991882d..361b311929 100644
--- a/tests/SlackNotificationChannel/Slack/Unit/Blocks/ImageBlockTest.php
+++ b/tests/SlackNotificationChannel/Slack/Unit/Blocks/ImageBlockTest.php
@@ -10,7 +10,7 @@
class ImageBlockTest extends TestCase
{
- public function testArrayable(): void
+ public function testItIsArrayable(): void
{
$block = new ImageBlock('http://placekitten.com/500/500', 'An incredibly cute kitten.');
@@ -21,7 +21,7 @@ public function testArrayable(): void
], $block->toArray());
}
- public function testUrlCantExceedThreeThousandCharacters(): void
+ public function testTheUrlCannotExceed3000Characters(): void
{
$this->expectException(LogicException::class);
$this->expectExceptionMessageIs('Maximum length for the url field is 3000 characters.');
@@ -29,7 +29,7 @@ public function testUrlCantExceedThreeThousandCharacters(): void
new ImageBlock(str_repeat('a', 3001));
}
- public function testAltTextIsRequired(): void
+ public function testTheAltTextIsRequired(): void
{
$this->expectException(LogicException::class);
$this->expectExceptionMessageIs('Alt text is required for an image block.');
@@ -39,7 +39,7 @@ public function testAltTextIsRequired(): void
$block->toArray();
}
- public function testAltTextCantExceedTwoThousandCharacters(): void
+ public function testTheAltTextCannotExceed2000Characters(): void
{
$this->expectException(LogicException::class);
$this->expectExceptionMessageIs('Maximum length for the alt text field is 2000 characters.');
@@ -58,7 +58,7 @@ public function testConstructorAltTextCantExceedTwoThousandCharacters(): void
new ImageBlock('http://placekitten.com/500/500', str_repeat('a', 2001));
}
- public function testCanHaveTitle(): void
+ public function testItCanHaveATitle(): void
{
$block = new ImageBlock('http://placekitten.com/500/500', 'An incredibly cute kitten.');
$block->title('This one is a cutesy kitten in a box.');
@@ -74,7 +74,7 @@ public function testCanHaveTitle(): void
], $block->toArray());
}
- public function testTitleCantExceedTwoThousandCharacters(): void
+ public function testTheTitleFieldCannotExceed2000Characters(): void
{
$block = new ImageBlock('http://placekitten.com/500/500', 'An incredibly cute kitten.');
$block->title(str_repeat('a', 2001));
@@ -90,7 +90,7 @@ public function testTitleCantExceedTwoThousandCharacters(): void
], $block->toArray());
}
- public function testCanManuallySpecifyBlockIdField(): void
+ public function testItCanManuallySpecifyTheBlockIdField(): void
{
$block = new ImageBlock('http://placekitten.com/500/500');
$block->alt('An incredibly cute kitten.');
@@ -111,7 +111,7 @@ public function testZeroBlockIdIsPreserved(): void
$this->assertSame('0', $block->toArray()['block_id']);
}
- public function testBlockIdCantExceedTwoFiveFiveCharacters(): void
+ public function testTheBlockIdFieldCannotExceed255Characters(): void
{
$this->expectException(LogicException::class);
$this->expectExceptionMessageIs('Maximum length for the block_id field is 255 characters.');
diff --git a/tests/SlackNotificationChannel/Slack/Unit/Blocks/SectionBlockTest.php b/tests/SlackNotificationChannel/Slack/Unit/Blocks/SectionBlockTest.php
index 0f57582ea4..37eab0db11 100644
--- a/tests/SlackNotificationChannel/Slack/Unit/Blocks/SectionBlockTest.php
+++ b/tests/SlackNotificationChannel/Slack/Unit/Blocks/SectionBlockTest.php
@@ -11,7 +11,7 @@
class SectionBlockTest extends TestCase
{
- public function testArrayable(): void
+ public function testItIsArrayable(): void
{
$block = new SectionBlock;
$block->text('Location: 123 Main Street, New York, NY 10010');
@@ -25,17 +25,17 @@ public function testArrayable(): void
], $block->toArray());
}
- public function testExceptionWithoutTextAndField(): void
+ public function testItThrowsAnExceptionWhenNoTextOrFieldWasProvided(): void
{
$this->expectException(LogicException::class);
- $this->expectExceptionMessageIs('A section requires at least one block, or the text to be set.');
+ $this->expectExceptionMessageIs('A section requires at least one field, or the text to be set.');
$block = new SectionBlock;
$block->toArray();
}
- public function testTextHasAtLeastOneCharacter(): void
+ public function testTheTextHasAMinimumLengthOfOneCharacter(): void
{
$this->expectException(LogicException::class);
$this->expectExceptionMessageIs('Text must be at least 1 character(s) long.');
@@ -46,7 +46,7 @@ public function testTextHasAtLeastOneCharacter(): void
$block->toArray();
}
- public function testTextCantExceedThreeThousandCharacters(): void
+ public function testTheTextCannotExceed3000Characters(): void
{
$block = new SectionBlock;
$block->text(str_repeat('a', 3001));
@@ -60,7 +60,7 @@ public function testTextCantExceedThreeThousandCharacters(): void
], $block->toArray());
}
- public function testTextCanBeCustomized(): void
+ public function testTheTextCanBeCustomized(): void
{
$block = new SectionBlock;
$block->text('Location: 123 Main Street, New York, NY 10010')->markdown();
@@ -74,7 +74,7 @@ public function testTextCanBeCustomized(): void
], $block->toArray());
}
- public function testNotAllowMoreThanTenFields(): void
+ public function testItDoesNotAllowMoreThanTenFields(): void
{
$this->expectException(LogicException::class);
$this->expectExceptionMessageIs('There is a maximum of 10 fields in each section block.');
@@ -87,7 +87,7 @@ public function testNotAllowMoreThanTenFields(): void
$block->toArray();
}
- public function testFieldCantExceedTwoThousandCharacters(): void
+ public function testAFieldCannotExceed2000Characters(): void
{
$block = new SectionBlock;
$block->field(str_repeat('a', 2001));
@@ -103,7 +103,7 @@ public function testFieldCantExceedTwoThousandCharacters(): void
], $block->toArray());
}
- public function testFieldCanBeCustomized(): void
+ public function testAFieldCanBeCustomized(): void
{
$block = new SectionBlock;
$block->field('Location: 123 Main Street, New York, NY 10010')->markdown();
@@ -119,7 +119,7 @@ public function testFieldCanBeCustomized(): void
], $block->toArray());
}
- public function testCanManuallySpecifyBlockIdField(): void
+ public function testItCanManuallySpecifyTheBlockIdField(): void
{
$block = new SectionBlock;
$block->text('Location: 123 Main Street, New York, NY 10010');
@@ -145,7 +145,7 @@ public function testZeroBlockIdIsPreservedAndEmptyFieldsRemainOmitted(): void
$this->assertArrayNotHasKey('fields', $block->toArray());
}
- public function testBlockIdCantExceedTwoFiveFiveCharacters(): void
+ public function testTheBlockIdFieldCannotExceed255Characters(): void
{
$this->expectException(LogicException::class);
$this->expectExceptionMessageIs('Maximum length for the block_id field is 255 characters.');
@@ -167,7 +167,7 @@ public function testBlockIdUsesTheSlackCharacterLimit(): void
$this->assertSame($id, $block->toArray()['block_id']);
}
- public function testCanSpecifyAccesoryElement(): void
+ public function testItCanSpecifyAnAccessoryElement(): void
{
$block = new SectionBlock;
$block->text('Location: 123 Main Street, New York, NY 10010');
diff --git a/tests/SlackNotificationChannel/Slack/Unit/Composites/ConfirmObjectTest.php b/tests/SlackNotificationChannel/Slack/Unit/Composites/ConfirmObjectTest.php
index 157d872cca..00e47aaac3 100644
--- a/tests/SlackNotificationChannel/Slack/Unit/Composites/ConfirmObjectTest.php
+++ b/tests/SlackNotificationChannel/Slack/Unit/Composites/ConfirmObjectTest.php
@@ -9,7 +9,7 @@
class ConfirmObjectTest extends TestCase
{
- public function testArrayable(): void
+ public function testItIsArrayable(): void
{
$object = new ConfirmObject;
@@ -33,7 +33,7 @@ public function testArrayable(): void
], $object->toArray());
}
- public function testTitleIsCustomizable(): void
+ public function testTheTitleFieldIsCustomizable(): void
{
$object = new ConfirmObject;
$object->title('This is a custom title.');
@@ -58,7 +58,7 @@ public function testTitleIsCustomizable(): void
], $object->toArray());
}
- public function testTitleTruncatedOverOneHundredCharacters(): void
+ public function testTheTitleGetsTruncatedWhenItExceeds100Characters(): void
{
$object = new ConfirmObject;
$object->title(str_repeat('a', 101));
@@ -83,7 +83,7 @@ public function testTitleTruncatedOverOneHundredCharacters(): void
], $object->toArray());
}
- public function testTextIsCustomizable(): void
+ public function testTheTextFieldIsCustomizable(): void
{
$object = new ConfirmObject;
$object->text('This is some custom text.');
@@ -108,7 +108,7 @@ public function testTextIsCustomizable(): void
], $object->toArray());
}
- public function testTextTruncatedOverThreeHundredCharacters(): void
+ public function testTheTextGetsTruncatedWhenItExceeds300Characters(): void
{
$objectA = new ConfirmObject(str_repeat('a', 301));
@@ -154,7 +154,7 @@ public function testTextTruncatedOverThreeHundredCharacters(): void
], $objectB->toArray());
}
- public function testConfirmIsCustomizable(): void
+ public function testTheConfirmFieldIsCustomizable(): void
{
$object = new ConfirmObject;
$object->confirm('Custom confirmation button.');
@@ -179,7 +179,7 @@ public function testConfirmIsCustomizable(): void
], $object->toArray());
}
- public function testConfirmTruncatedOverThirtyCharacters(): void
+ public function testTheConfirmFieldIsGetsTruncatedAfter30Characters(): void
{
$object = new ConfirmObject;
$object->confirm(str_repeat('a', 31));
@@ -204,7 +204,7 @@ public function testConfirmTruncatedOverThirtyCharacters(): void
], $object->toArray());
}
- public function testColorSchemeWithDanger(): void
+ public function testTheColorSchemeCanBeSetToDanger(): void
{
$object = new ConfirmObject;
$object->danger();
@@ -230,7 +230,7 @@ public function testColorSchemeWithDanger(): void
], $object->toArray());
}
- public function testDenyIsCustomizable(): void
+ public function testTheDenyFieldIsCustomizable(): void
{
$object = new ConfirmObject;
$object->deny('Custom deny button.');
@@ -255,7 +255,7 @@ public function testDenyIsCustomizable(): void
], $object->toArray());
}
- public function testDenyTruncatedOverThirtyCharacters(): void
+ public function testTheDenyFieldIsGetsTruncatedAfter30Characters(): void
{
$object = new ConfirmObject;
$object->deny(str_repeat('a', 31));
diff --git a/tests/SlackNotificationChannel/Slack/Unit/Composites/PlainTextOnlyTextObjectTest.php b/tests/SlackNotificationChannel/Slack/Unit/Composites/PlainTextOnlyTextObjectTest.php
index 6355efdc4b..883f1ac282 100644
--- a/tests/SlackNotificationChannel/Slack/Unit/Composites/PlainTextOnlyTextObjectTest.php
+++ b/tests/SlackNotificationChannel/Slack/Unit/Composites/PlainTextOnlyTextObjectTest.php
@@ -10,7 +10,7 @@
class PlainTextOnlyTextObjectTest extends TestCase
{
- public function testArrayable(): void
+ public function testItIsArrayable(): void
{
$object = new PlainTextOnlyTextObject('A message *with some bold text* and _some italicized text_.');
@@ -20,7 +20,7 @@ public function testArrayable(): void
], $object->toArray());
}
- public function testTextHasAtLeastOneCharacter(): void
+ public function testTheTextHasAMinimumLengthOf1Character(): void
{
$this->expectException(LogicException::class);
$this->expectExceptionMessageIs('Text must be at least 1 character(s) long.');
@@ -28,7 +28,7 @@ public function testTextHasAtLeastOneCharacter(): void
new PlainTextOnlyTextObject('');
}
- public function testTextTruncatedOverThreeThousandCharacters(): void
+ public function testTheTextGetsTruncatedWhenItExceeds3000Characters(): void
{
$object = new PlainTextOnlyTextObject(str_repeat('a', 3001));
@@ -65,7 +65,7 @@ public function testMalformedOverLimitTextIsRejectedBeforeTruncation(): void
new PlainTextOnlyTextObject(str_repeat('a', 3001) . "\xFF");
}
- public function testEscapeEmojiColonFormat(): void
+ public function testItCanIndicateThatEmojisShouldBeEscapedIntoTheColonEmojiFormat(): void
{
$object = new PlainTextOnlyTextObject('Spooky time! 👻');
$object->emoji();
diff --git a/tests/SlackNotificationChannel/Slack/Unit/Composites/TextObjectTest.php b/tests/SlackNotificationChannel/Slack/Unit/Composites/TextObjectTest.php
index b9187ab694..4e3451a734 100644
--- a/tests/SlackNotificationChannel/Slack/Unit/Composites/TextObjectTest.php
+++ b/tests/SlackNotificationChannel/Slack/Unit/Composites/TextObjectTest.php
@@ -10,7 +10,7 @@
class TextObjectTest extends TestCase
{
- public function testArrayable(): void
+ public function testItIsArrayable(): void
{
$object = new TextObject('A message *with some bold text* and _some italicized text_.');
@@ -20,7 +20,7 @@ public function testArrayable(): void
], $object->toArray());
}
- public function testMarkdownTextField(): void
+ public function testItCanBeAMarkdownTextField(): void
{
$object = new TextObject('A message *with some bold text* and _some italicized text_.');
$object->markdown();
@@ -31,7 +31,7 @@ public function testMarkdownTextField(): void
], $object->toArray());
}
- public function testTextHasAtLeastOneCharacter(): void
+ public function testTheTextHasAMinimumLengthOf1Character(): void
{
$this->expectException(LogicException::class);
$this->expectExceptionMessageIs('Text must be at least 1 character(s) long.');
@@ -39,7 +39,7 @@ public function testTextHasAtLeastOneCharacter(): void
new TextObject('');
}
- public function testTextTruncatedOverThreeThousandCharacters(): void
+ public function testTheTextGetsTruncatedWhenItExceeds3000Characters(): void
{
$object = new TextObject(str_repeat('a', 3001));
@@ -49,7 +49,7 @@ public function testTextTruncatedOverThreeThousandCharacters(): void
], $object->toArray());
}
- public function testEscapeEmojiColonFormat(): void
+ public function testItCanIndicateThatEmojisShouldBeEscapedIntoTheColonEmojiFormat(): void
{
$object = new TextObject('Spooky time! 👻');
$object->emoji();
@@ -61,7 +61,7 @@ public function testEscapeEmojiColonFormat(): void
], $object->toArray());
}
- public function testEscapeEmojiColonFormatWhenMarkdown(): void
+ public function testItCannotIndicateThatEmojisShouldBeEscapedIntoTheColonEmojiFormatWhenUsingMarkdown(): void
{
$object = new TextObject('Spooky time! 👻');
$object->markdown()->emoji();
@@ -72,7 +72,7 @@ public function testEscapeEmojiColonFormatWhenMarkdown(): void
], $object->toArray());
}
- public function testSkipClickableAnchors(): void
+ public function testItCanIndicateThatAutoConversionIntoClickableAnchorsShouldBeSkipped(): void
{
$object = new TextObject('A message *with some bold text* and _some italicized text_.');
$object->markdown()->verbatim();
@@ -84,7 +84,7 @@ public function testSkipClickableAnchors(): void
], $object->toArray());
}
- public function testSkipClickableAnchorsWhenPlaintext(): void
+ public function testItCannotIndicateThatAutoConversionIntoClickableAnchorsShouldBeSkippedWhenUsingPlaintext(): void
{
$object = new TextObject('A message *with some bold text* and _some italicized text_.');
$object->verbatim();
diff --git a/tests/SlackNotificationChannel/Slack/Unit/Elements/ButtonElementTest.php b/tests/SlackNotificationChannel/Slack/Unit/Elements/ButtonElementTest.php
index a9e9761caa..d5c5e7265a 100644
--- a/tests/SlackNotificationChannel/Slack/Unit/Elements/ButtonElementTest.php
+++ b/tests/SlackNotificationChannel/Slack/Unit/Elements/ButtonElementTest.php
@@ -12,7 +12,7 @@
class ButtonElementTest extends TestCase
{
- public function testArrayable(): void
+ public function testItIsArrayable(): void
{
$element = new ButtonElement('Click Me');
@@ -26,7 +26,7 @@ public function testArrayable(): void
], $element->toArray());
}
- public function testTextLengthIsSeventyFiveCharacters(): void
+ public function testTheMaximumTextLengthIs75Characters(): void
{
$element = new ButtonElement(str_repeat('a', 250));
@@ -40,9 +40,9 @@ public function testTextLengthIsSeventyFiveCharacters(): void
], $element->toArray());
}
- public function testTextCanBeCustomized(): void
+ public function testTheTextCanBeCustomized(): void
{
- $element = new ButtonElement('Click Me', function (PlainTextOnlyTextObject $textObject) {
+ $element = new ButtonElement('Click Me', function (PlainTextOnlyTextObject $textObject): void {
$textObject->emoji();
});
@@ -57,7 +57,7 @@ public function testTextCanBeCustomized(): void
], $element->toArray());
}
- public function testActionIdCanBeCustomized(): void
+ public function testTheActionIdCanBeCustomized(): void
{
$element = new ButtonElement('Click Me');
$element->id('custom_action_id');
@@ -72,7 +72,7 @@ public function testActionIdCanBeCustomized(): void
], $element->toArray());
}
- public function testActionIdCantExceedTwoFiveFiveCharacters(): void
+ public function testTheActionIdCannotExceed255Characters(): void
{
$this->expectException(InvalidArgumentException::class);
$this->expectExceptionMessageIs('Maximum length for the action_id field is 255 characters.');
@@ -101,7 +101,7 @@ public function testGeneratedActionIdsFallbackWhenTextCannotBeSlugged(): void
$this->assertNotSame($firstId, $secondId);
}
- public function testCanHaveUrl(): void
+ public function testItCanHaveAnUrl(): void
{
$element = new ButtonElement('Click Me');
$element->url('https://hypervel.org');
@@ -117,7 +117,7 @@ public function testCanHaveUrl(): void
], $element->toArray());
}
- public function testUrlCantExceedThreeThousandCharacters(): void
+ public function testTheUrlCannotExceed3000Characters(): void
{
$this->expectException(InvalidArgumentException::class);
$this->expectExceptionMessageIs('Maximum length for the url field is 3000 characters.');
@@ -128,7 +128,7 @@ public function testUrlCantExceedThreeThousandCharacters(): void
$element->toArray();
}
- public function testCanHaveValue(): void
+ public function testItCanHaveAValue(): void
{
$element = new ButtonElement('Click Me');
$element->value('click_me_123');
@@ -173,7 +173,7 @@ public function testZeroValueAndAccessibilityLabelArePreserved(): void
$this->assertSame('0', $payload['accessibility_label']);
}
- public function testValueCantExceedTwoThousandCharacters(): void
+ public function testTheValueCannotExceed2000Characters(): void
{
$this->expectException(InvalidArgumentException::class);
$this->expectExceptionMessageIs('Maximum length for the value field is 2000 characters.');
@@ -184,7 +184,7 @@ public function testValueCantExceedTwoThousandCharacters(): void
$element->toArray();
}
- public function testCanHavePrimaryStyle(): void
+ public function testItCanHaveThePrimaryStyle(): void
{
$element = new ButtonElement('Click Me');
$element->primary();
@@ -200,7 +200,7 @@ public function testCanHavePrimaryStyle(): void
], $element->toArray());
}
- public function testCanHaveDangerStyle(): void
+ public function testItCanHaveTheDangerStyle(): void
{
$element = new ButtonElement('Click Me');
$element->danger();
@@ -216,7 +216,7 @@ public function testCanHaveDangerStyle(): void
], $element->toArray());
}
- public function testCanHaveConfirmableDialog(): void
+ public function testItCanHaveAConfirmationDialog(): void
{
$element = new ButtonElement('Click Me');
$element->confirm('This will do some thing.')->deny('Yikes!');
@@ -249,10 +249,10 @@ public function testCanHaveConfirmableDialog(): void
], $element->toArray());
}
- public function testConfirmationWithMultipleOptions(): void
+ public function testItCanScopeTheConfirmationDialogAndSetMultipleOptions(): void
{
$element = new ButtonElement('Click Me');
- $element->confirm('This will do some thing.', function (ConfirmObject $dialog) {
+ $element->confirm('This will do some thing.', function (ConfirmObject $dialog): void {
$dialog->deny('Yikes!');
$dialog->confirm('Woohoo!');
});
@@ -285,7 +285,7 @@ public function testConfirmationWithMultipleOptions(): void
], $element->toArray());
}
- public function testCanHaveAccessibilityLabel(): void
+ public function testItCanHaveAnAccessibilityLabel(): void
{
$element = new ButtonElement('Click Me');
$element->accessibilityLabel('Click Me Button');
@@ -301,7 +301,7 @@ public function testCanHaveAccessibilityLabel(): void
], $element->toArray());
}
- public function testAccessibilityLabelCantExceedSeventyFiveCharacters(): void
+ public function testTheAccessibilityLabelCannotExceed75Characters(): void
{
$this->expectException(InvalidArgumentException::class);
$this->expectExceptionMessageIs('Maximum length for the accessibility label is 75 characters.');
diff --git a/tests/SlackNotificationChannel/Slack/Unit/Elements/Selects/SelectOptionTest.php b/tests/SlackNotificationChannel/Slack/Unit/Elements/Selects/SelectOptionTest.php
index fc0ea5dd2a..cbfd63ec03 100644
--- a/tests/SlackNotificationChannel/Slack/Unit/Elements/Selects/SelectOptionTest.php
+++ b/tests/SlackNotificationChannel/Slack/Unit/Elements/Selects/SelectOptionTest.php
@@ -18,6 +18,9 @@ public function testOptionValuesArePreserved(Stringable|string|int|float|bool $v
$this->assertSame($expected, (new SelectOption('Example', $value))->toArray()['value']);
}
+ /**
+ * Provide option values and their serialized strings.
+ */
public static function validOptionValues(): iterable
{
yield 'string' => ['Example Value', 'Example Value'];
@@ -27,6 +30,9 @@ public static function validOptionValues(): iterable
yield 'punctuation' => ['!@#$%^&*()', '!@#$%^&*()'];
yield 'non-latin' => ['你好', '你好'];
yield 'stringable' => [new class implements Stringable {
+ /**
+ * Get the string value.
+ */
public function __toString(): string
{
return 'Stringable Value';
@@ -44,6 +50,9 @@ public function testEmptyOptionValuesAreRejected(
new SelectOption('Example', $value);
}
+ /**
+ * Provide option values that serialize to an empty string.
+ */
public static function invalidOptionValues(): iterable
{
yield 'empty' => [''];
diff --git a/tests/SlackNotificationChannel/Slack/Unit/Elements/Selects/UsersSelectElementTest.php b/tests/SlackNotificationChannel/Slack/Unit/Elements/Selects/UsersSelectElementTest.php
index a067b21567..a5ce8d1443 100644
--- a/tests/SlackNotificationChannel/Slack/Unit/Elements/Selects/UsersSelectElementTest.php
+++ b/tests/SlackNotificationChannel/Slack/Unit/Elements/Selects/UsersSelectElementTest.php
@@ -35,19 +35,4 @@ public function testItGeneratesADeterministicActionIdFromText(): void
$this->assertSame('users_select_example-user', $select->toArray()['action_id']);
}
-
- public function testGeneratedActionIdRespectsTheSlackLimit(): void
- {
- $select = new UsersSelectElement(str_repeat('a', 248));
-
- $this->assertSame(255, strlen($select->toArray()['action_id']));
- }
-
- public function testDirectConstructionWithoutTextGeneratesAnActionId(): void
- {
- $actionId = (new UsersSelectElement)->toArray()['action_id'];
-
- $this->assertStringStartsWith('users_select_', $actionId);
- $this->assertNotSame('users_select_', $actionId);
- }
}
From 7bde916b23dc5f2af358949bb6c652cc09175146 Mon Sep 17 00:00:00 2001
From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com>
Date: Thu, 8 Oct 2026 13:50:49 +0000
Subject: [PATCH 08/18] Reconcile the Slack notification channel with upstream
Compare the webhook and Web API channels, messages, router, provider,
tests and documentation with laravel/slack-notification-channel 3.x
(7b7c3e220a4f7a45cd88d65069f28d4fb4b67281) and Laravel docs 13.x
(226b0649c77e1d6fe739a20e1da654e94ccc718f).
Fixes:
- SlackAttachment::field() used is_callable(), so titles that name PHP
functions, such as "Date" or "Count", were called instead of used as
titles. Only closures are now treated as field builders.
- Webhook sends used a Guzzle client with no request timeout, so a
stalled host held the send indefinitely. The provider now gives the
webhook channel 10-second connect and 30-second request timeouts;
applications can replace the client and attachment messages can
override options with http().
Upstream alignment:
- Restore the legacy message docblock, the nullable callback ID default
and the inlined Web API URL; type the remaining callbacks.
- Rename upstream-derived tests to upstream's names and order, restore
the router's channel-string case, the Block Kit Builder URL
assertions and the shared helper's Content-Type check, and reuse the
shared notifiable fixture in the router tests.
Documentation:
- Restore Laravel's installation and Slack App wording, document the
webhook URL and false routes with an Incoming Webhooks section, and
label the scopes, token and HTTP connection as Web API settings.
- Record the verbatim select option value difference in the README and
set the Slack sync checkpoint.
Validated with the Slack suite, Horizon's LongWaitDetectedTest,
composer lint:fix and composer analyse.
---
docs/upstream-sync/sync.yaml | 5 +-
src/docs/notifications.md | 58 +++-
.../src/Notifications/LongWaitDetected.php | 3 +-
src/slack-notification-channel/README.md | 4 +
.../src/Channels/SlackWebhookChannel.php | 4 +-
.../src/Messages/SlackAttachment.php | 5 +-
.../src/Messages/SlackMessage.php | 9 +-
.../src/Slack/SlackChannel.php | 4 +-
.../src/Slack/SlackMessage.php | 6 +-
.../src/SlackChannelServiceProvider.php | 7 +
.../NotificationSlackChannelTest.php | 56 ++-
.../NotificationTransportQueueTest.php | 8 +-
.../Slack/Feature/SlackChannelTest.php | 35 +-
.../Slack/Feature/SlackMessageTest.php | 325 ++++++++----------
.../Fixtures/SlackChannelTestNotifiable.php | 17 +-
.../Fixtures/SlackChannelTestNotification.php | 11 +-
.../Slack/TestCase.php | 13 +-
.../SlackChannelServiceProviderTest.php | 31 ++
.../SlackNotificationRouterChannelTest.php | 37 +-
19 files changed, 375 insertions(+), 263 deletions(-)
diff --git a/docs/upstream-sync/sync.yaml b/docs/upstream-sync/sync.yaml
index 17b985b61a..4af25b190d 100644
--- a/docs/upstream-sync/sync.yaml
+++ b/docs/upstream-sync/sync.yaml
@@ -107,9 +107,10 @@ laravel/scout:
laravel/slack-notification-channel:
branch: 3.x
- checked_through: null
+ checked_through: 7b7c3e220a4f7a45cd88d65069f28d4fb4b67281
last_reviewed_pr: null
- sync_date: null
+ sync_date: '2026-10-08'
+ notes: Upstream src/ maps to src/slack-notification-channel/src and tests/ to tests/SlackNotificationChannel; the two tests/Slack notification fixtures live in Slack/Fixtures, and the feature tests' sendNotification and assertNotificationSent helpers live in Slack/TestCase. User documentation is the Slack Notifications section of src/docs/notifications.md, which also documents the webhook routes Laravel's docs omit. The Web API channel sends through the slack-notifications HTTP connection (SlackChannel::CONNECTION), so keep connection() when porting request changes. Feature tests fake Slack's API responses instead of calling Slack, and router tests register Mockery channel instances because determineChannel() returns typed channels. Horizon's LongWaitDetected builds both message types; run its test after message API changes.
laravel/telescope:
branch: 5.x
diff --git a/src/docs/notifications.md b/src/docs/notifications.md
index 800f123080..2f08e71617 100644
--- a/src/docs/notifications.md
+++ b/src/docs/notifications.md
@@ -39,6 +39,7 @@
- [Slack Interactivity](#slack-interactivity)
- [Routing Slack Notifications](#routing-slack-notifications)
- [Notifying External Slack Workspaces](#notifying-external-slack-workspaces)
+ - [Incoming Webhooks](#slack-incoming-webhooks)
- [Localizing Notifications](#localizing-notifications)
- [Testing](#testing)
- [Notification Events](#notification-events)
@@ -1369,13 +1370,15 @@ class User extends Authenticatable
### Prerequisites
-Before sending Slack notifications, install the Slack notification channel using Composer:
+Before sending Slack notifications, you should install the Slack notification channel via Composer:
```shell
composer require hypervel/slack-notification-channel
```
-Next, create a [Slack App](https://api.slack.com/apps?new_app=1) for your Slack workspace.
+Additionally, to send notifications to Slack, you must create a [Slack App](https://api.slack.com/apps?new_app=1) for your Slack workspace.
+
+The scopes and token described below are used to send notifications through Slack's Web API. If you only send notifications to [incoming webhooks](#slack-incoming-webhooks), you do not need them.
If you only need to send notifications to the same Slack workspace that the App is created in, you should ensure that your App has the `chat:write`, `chat:write.public`, and `chat:write.customize` scopes. These scopes can be added from the "OAuth & Permissions" App management tab within Slack.
@@ -1398,7 +1401,7 @@ If your application will be sending notifications to external Slack workspaces t
#### HTTP Client
-Slack API notifications use Hypervel's [HTTP client](/docs/{{version}}/http-client) and its `slack-notifications` connection. The connection is registered automatically and uses the HTTP client's default connection timeout of 10 seconds and request timeout of 30 seconds. You may customize its options in your application's `AppServiceProvider`:
+Slack Web API notifications use Hypervel's [HTTP client](/docs/{{version}}/http-client) and its `slack-notifications` connection. The connection is registered automatically and uses the HTTP client's default connection timeout of 10 seconds and request timeout of 30 seconds. You may customize its options in your application's `AppServiceProvider`:
```php
use Hypervel\Http\Client\Factory;
@@ -1588,11 +1591,13 @@ return (new SlackMessage)
### Routing Slack Notifications
-To direct Slack notifications to the appropriate Slack team and channel, define a `routeNotificationForSlack` method on your notifiable model. This method can return one of three values:
+To direct Slack notifications to the appropriate Slack team and channel, define a `routeNotificationForSlack` method on your notifiable model. This method can return one of the following values:
- `null` - which defers routing to the channel configured in the notification itself. You may use the `to` method when building your `SlackMessage` to configure the channel within the notification.
- A string specifying the Slack channel to send the notification to, e.g. `#support-channel`.
- A `SlackRoute` instance, which allows you to specify an OAuth token and channel name, e.g. `SlackRoute::make($this->slack_channel, $this->slack_token)`. This method should be used to send notifications to external workspaces.
+- A webhook URL, as a string or `Psr\Http\Message\UriInterface` instance, which sends the notification to an [incoming webhook](#slack-incoming-webhooks).
+- `false` - which skips sending the notification to Slack.
For instance, returning `#support-channel` from the `routeNotificationForSlack` method will send the notification to the `#support-channel` channel in the workspace associated with the Bot User OAuth token located in your application's `services.php` configuration file:
@@ -1653,6 +1658,51 @@ class User extends Authenticatable
}
```
+
+### Incoming Webhooks
+
+Instead of using Slack's Web API, you may send notifications to an [incoming webhook](https://docs.slack.dev/messaging/sending-messages-using-incoming-webhooks). After enabling incoming webhooks for your Slack App, Slack will provide a webhook URL for the channel you choose. Return this URL from your notifiable model's `routeNotificationForSlack` method. Since the URL may be stored on the model, each user or team can receive notifications through their own webhook:
+
+```php
+use Hypervel\Notifications\Notification;
+
+/**
+ * Route notifications for the Slack channel.
+ */
+public function routeNotificationForSlack(Notification $notification): mixed
+{
+ return $this->slack_webhook_url;
+}
+```
+
+Slack's incoming webhooks accept the Block Kit messages described above. For Slack-compatible services such as [Mattermost](https://docs.mattermost.com/integrations-guide/faq), use Slack's attachment format by returning a `Hypervel\Notifications\Messages\SlackMessage` instance from your notification's `toSlack` method:
+
+```php
+use Hypervel\Notifications\Messages\SlackAttachment;
+use Hypervel\Notifications\Messages\SlackMessage;
+
+/**
+ * Get the Slack representation of the notification.
+ */
+public function toSlack(object $notifiable): SlackMessage
+{
+ $url = url('/invoices/'.$this->invoice->id);
+
+ return (new SlackMessage)
+ ->success()
+ ->content('One of your invoices has been paid!')
+ ->attachment(function (SlackAttachment $attachment) use ($url) {
+ $attachment->title('Invoice 1322', $url)
+ ->fields([
+ 'Title' => 'Server Expenses',
+ 'Amount' => '$1,234',
+ ]);
+ });
+}
+```
+
+Webhook requests use a connection timeout of 10 seconds and a request timeout of 30 seconds. Attachment messages may override these and other [Guzzle request options](https://docs.guzzlephp.org/en/stable/request-options.html) using the `http` method, such as `->http(['timeout' => 10])`.
+
## Localizing Notifications
diff --git a/src/horizon/src/Notifications/LongWaitDetected.php b/src/horizon/src/Notifications/LongWaitDetected.php
index da1c5fe26c..cbd9ec0736 100644
--- a/src/horizon/src/Notifications/LongWaitDetected.php
+++ b/src/horizon/src/Notifications/LongWaitDetected.php
@@ -8,6 +8,7 @@
use Hypervel\Horizon\Contracts\LongWaitDetectedNotification;
use Hypervel\Horizon\Horizon;
use Hypervel\Notifications\Messages\MailMessage;
+use Hypervel\Notifications\Messages\SlackAttachment;
use Hypervel\Notifications\Messages\SlackMessage;
use Hypervel\Notifications\Notification;
use Hypervel\Notifications\Slack\BlockKit\Blocks\SectionBlock;
@@ -94,7 +95,7 @@ public function toSlack(mixed $notifiable): ChannelIdSlackMessage|SlackMessage
->to(Horizon::$slackChannel)
->error()
->content($text)
- ->attachment(function ($attachment) use ($title, $content) {
+ ->attachment(function (SlackAttachment $attachment) use ($title, $content): void {
$attachment->title($title)
->content($content);
});
diff --git a/src/slack-notification-channel/README.md b/src/slack-notification-channel/README.md
index c39359a762..27af772498 100644
--- a/src/slack-notification-channel/README.md
+++ b/src/slack-notification-channel/README.md
@@ -3,4 +3,8 @@ Slack Notification Channel for Hypervel
Documentation: https://hypervel.org/docs/notifications
+## Differences From Laravel
+
+- Slack select option values are preserved exactly, rather than lowercased and stripped of characters, so distinct values remain distinct. Interaction handlers should match the original values.
+
Ported from: https://github.com/laravel/slack-notification-channel
diff --git a/src/slack-notification-channel/src/Channels/SlackWebhookChannel.php b/src/slack-notification-channel/src/Channels/SlackWebhookChannel.php
index 2c8df4a2c7..6388f13d99 100644
--- a/src/slack-notification-channel/src/Channels/SlackWebhookChannel.php
+++ b/src/slack-notification-channel/src/Channels/SlackWebhookChannel.php
@@ -69,7 +69,7 @@ public function buildJsonPayload(SlackMessage|LegacySlackMessage $message): arra
*/
protected function attachments(LegacySlackMessage $message): array
{
- return Collection::make($message->attachments)->map(function ($attachment) use ($message) {
+ return Collection::make($message->attachments)->map(function (SlackAttachment $attachment) use ($message): array {
return array_filter([
'actions' => $attachment->actions,
'author_icon' => $attachment->authorIcon,
@@ -98,7 +98,7 @@ protected function attachments(LegacySlackMessage $message): array
*/
protected function fields(SlackAttachment $attachment): array
{
- return Collection::make($attachment->fields)->map(function ($value, $key) {
+ return Collection::make($attachment->fields)->map(function (mixed $value, int|string $key): array {
if ($value instanceof SlackAttachmentField) {
return $value->toArray();
}
diff --git a/src/slack-notification-channel/src/Messages/SlackAttachment.php b/src/slack-notification-channel/src/Messages/SlackAttachment.php
index b3e0e18907..a6b11e334e 100644
--- a/src/slack-notification-channel/src/Messages/SlackAttachment.php
+++ b/src/slack-notification-channel/src/Messages/SlackAttachment.php
@@ -101,7 +101,7 @@ class SlackAttachment
/**
* The attachment's callback ID.
*/
- public string $callbackId = '';
+ public ?string $callbackId = null;
/**
* Set the title of the attachment.
@@ -159,7 +159,8 @@ public function color(string $color): static
*/
public function field(Closure|string $title, string $content = ''): static
{
- if (is_callable($title)) {
+ // Titles such as "Date" also name PHP functions, so is_callable() would call them.
+ if ($title instanceof Closure) {
$callback = $title;
$callback($attachmentField = new SlackAttachmentField);
diff --git a/src/slack-notification-channel/src/Messages/SlackMessage.php b/src/slack-notification-channel/src/Messages/SlackMessage.php
index 3af1ab5733..8c5e769621 100644
--- a/src/slack-notification-channel/src/Messages/SlackMessage.php
+++ b/src/slack-notification-channel/src/Messages/SlackMessage.php
@@ -5,7 +5,14 @@
namespace Hypervel\Notifications\Messages;
use Closure;
-
+use Hypervel\Notifications\Slack\SlackMessage as BlockKitSlackMessage;
+
+/**
+ * Legacy Slack message with attachments.
+ *
+ * @link https://api.slack.com/legacy/outmoded-messaging
+ * @see BlockKitSlackMessage
+ */
class SlackMessage
{
/**
diff --git a/src/slack-notification-channel/src/Slack/SlackChannel.php b/src/slack-notification-channel/src/Slack/SlackChannel.php
index 18264368cd..960abd0988 100644
--- a/src/slack-notification-channel/src/Slack/SlackChannel.php
+++ b/src/slack-notification-channel/src/Slack/SlackChannel.php
@@ -18,8 +18,6 @@ class SlackChannel
*/
public const string CONNECTION = 'slack-notifications';
- protected const string SLACK_API_URL = 'https://slack.com/api/chat.postMessage';
-
/**
* Create a new Slack channel instance.
*/
@@ -50,7 +48,7 @@ public function send(mixed $notifiable, Notification $notification): ?Response
$response = $this->http->connection(self::CONNECTION)
->asJson()
->withToken($route->token)
- ->post(static::SLACK_API_URL, $payload)
+ ->post('https://slack.com/api/chat.postMessage', $payload)
->throw();
if ($response->successful() && $response->json('ok') === false) {
diff --git a/src/slack-notification-channel/src/Slack/SlackMessage.php b/src/slack-notification-channel/src/Slack/SlackMessage.php
index 5f013a1e0e..83d75dbe01 100644
--- a/src/slack-notification-channel/src/Slack/SlackMessage.php
+++ b/src/slack-notification-channel/src/Slack/SlackMessage.php
@@ -305,7 +305,7 @@ public function toArray(): array
$optionalFields = array_filter([
'text' => $this->text,
- 'blocks' => ! empty($this->blocks) ? array_map(fn ($block) => $block instanceof BlockContract ? $block->toArray() : $block, $this->blocks) : null,
+ 'blocks' => ! empty($this->blocks) ? array_map(fn (array|BlockContract $block): array => $block instanceof BlockContract ? $block->toArray() : $block, $this->blocks) : null,
'icon_emoji' => $this->icon,
'icon_url' => $this->image,
'metadata' => $this->metaData?->toArray(),
@@ -315,7 +315,7 @@ public function toArray(): array
'unfurl_links' => $this->unfurlLinks,
'unfurl_media' => $this->unfurlMedia,
'username' => $this->username,
- ], fn ($value) => $value !== null);
+ ], fn (mixed $value): bool => $value !== null);
return array_merge([
'channel' => $this->channel,
@@ -323,7 +323,7 @@ public function toArray(): array
}
/**
- * Get the payload as a URL to the Slack Block Kit Builder.
+ * Get the Block Kit URL for the message.
*
* @throws JsonException
*/
diff --git a/src/slack-notification-channel/src/SlackChannelServiceProvider.php b/src/slack-notification-channel/src/SlackChannelServiceProvider.php
index e783657e6c..470c90fb98 100644
--- a/src/slack-notification-channel/src/SlackChannelServiceProvider.php
+++ b/src/slack-notification-channel/src/SlackChannelServiceProvider.php
@@ -4,8 +4,10 @@
namespace Hypervel\Notifications;
+use GuzzleHttp\Client as HttpClient;
use Hypervel\Contracts\Container\Container;
use Hypervel\Http\Client\Factory;
+use Hypervel\Notifications\Channels\SlackWebhookChannel;
use Hypervel\Notifications\Slack\SlackChannel;
use Hypervel\Support\Facades\Notification;
use Hypervel\Support\ServiceProvider;
@@ -22,6 +24,11 @@ public function register(): void
return $app->make(SlackNotificationRouterChannel::class);
});
});
+
+ // Guzzle has no request timeout by default, so a stalled webhook host would hold the send indefinitely.
+ $this->app->when(SlackWebhookChannel::class)
+ ->needs(HttpClient::class)
+ ->give(fn (): HttpClient => new HttpClient(['connect_timeout' => 10, 'timeout' => 30]));
}
/**
diff --git a/tests/SlackNotificationChannel/NotificationSlackChannelTest.php b/tests/SlackNotificationChannel/NotificationSlackChannelTest.php
index 87b87b6b5d..ea39b202b1 100644
--- a/tests/SlackNotificationChannel/NotificationSlackChannelTest.php
+++ b/tests/SlackNotificationChannel/NotificationSlackChannelTest.php
@@ -27,7 +27,7 @@ public function testCorrectPayloadIsSentToSlack(Notification $notification, arra
$slackChannel = new SlackWebhookChannel($guzzleHttp);
- $guzzleHttp->shouldReceive('post')->andReturnUsing(function (string $argUrl, array $argPayload) use ($payload) {
+ $guzzleHttp->shouldReceive('post')->andReturnUsing(function (string $argUrl, array $argPayload) use ($payload): Response {
$this->assertSame('url', $argUrl);
$this->assertEquals($payload, $argPayload);
@@ -37,6 +37,9 @@ public function testCorrectPayloadIsSentToSlack(Notification $notification, arra
$slackChannel->send(new SlackChannelTestNotifiable('url'), $notification);
}
+ /**
+ * Provide legacy notifications and their webhook payloads.
+ */
public static function payloadDataProvider(): array
{
return [
@@ -51,6 +54,9 @@ public function testModernBlockKitPayloadIsSentToSlack(): void
{
$guzzleHttp = m::mock(Client::class);
$notification = new class extends Notification {
+ /**
+ * Get the Slack representation of the notification.
+ */
public function toSlack(mixed $notifiable): SlackMessage
{
return (new SlackMessage)->text('Modern content');
@@ -131,6 +137,20 @@ public function testZeroAttachmentValuesArePreserved(): void
], $payload['json']['attachments'][0]);
}
+ public function testAttachmentFieldTitlesThatNamePhpFunctionsAreNotCalled(): void
+ {
+ $message = (new LegacySlackMessage)->content('Content')->attachment(function (SlackAttachment $attachment): void {
+ $attachment->field('Date', '2026-10-08')->field('Count', '3');
+ });
+
+ $payload = (new SlackWebhookChannel(new Client))->buildJsonPayload($message);
+
+ $this->assertSame([
+ ['title' => 'Date', 'value' => '2026-10-08', 'short' => true],
+ ['title' => 'Count', 'value' => '3', 'short' => true],
+ ], $payload['json']['attachments'][0]['fields']);
+ }
+
public function testDisabledWebhookDoesNotRequireASlackMessageMethod(): void
{
$http = m::mock(Client::class);
@@ -153,6 +173,9 @@ public function testRawWebhookRequestOptionsArePreserved(): void
$this->assertSame(['X-Notification' => 'slack'], $payload['headers']);
}
+ /**
+ * Get a notification with an emoji icon and its payload.
+ */
protected static function getPayloadWithIcon(): array
{
return [
@@ -190,6 +213,9 @@ protected static function getPayloadWithIcon(): array
];
}
+ /**
+ * Get a notification with an image icon and its payload.
+ */
protected static function getPayloadWithImageIcon(): array
{
return [
@@ -224,6 +250,9 @@ protected static function getPayloadWithImageIcon(): array
];
}
+ /**
+ * Get a notification without optional fields and its payload.
+ */
protected static function getPayloadWithoutOptionalFields(): array
{
return [
@@ -250,6 +279,9 @@ protected static function getPayloadWithoutOptionalFields(): array
];
}
+ /**
+ * Get a notification using the attachment field builder and its payload.
+ */
protected static function getPayloadWithAttachmentFieldBuilder(): array
{
return [
@@ -285,13 +317,16 @@ protected static function getPayloadWithAttachmentFieldBuilder(): array
class NotificationSlackChannelTestNotification extends Notification
{
+ /**
+ * Get the Slack representation of the notification.
+ */
public function toSlack(mixed $notifiable): LegacySlackMessage
{
return (new LegacySlackMessage)
->from('Ghostbot', ':ghost:')
->to('#ghost-talk')
->content('Content')
- ->attachment(function (SlackAttachment $attachment) {
+ ->attachment(function (SlackAttachment $attachment): void {
$timestamp = CarbonImmutable::createFromTimestamp(1234567890);
$attachment->title('Hypervel', 'https://hypervel.org')
->content('Attachment Content')
@@ -310,6 +345,9 @@ public function toSlack(mixed $notifiable): LegacySlackMessage
class NotificationSlackChannelTestNotificationWithImageIcon extends Notification
{
+ /**
+ * Get the Slack representation of the notification.
+ */
public function toSlack(mixed $notifiable): LegacySlackMessage
{
return (new LegacySlackMessage)
@@ -317,7 +355,7 @@ public function toSlack(mixed $notifiable): LegacySlackMessage
->image('http://example.com/image.png')
->to('#ghost-talk')
->content('Content')
- ->attachment(function (SlackAttachment $attachment) {
+ ->attachment(function (SlackAttachment $attachment): void {
$timestamp = CarbonImmutable::createFromTimestamp(1234567890);
$attachment->title('Hypervel', 'https://hypervel.org')
->content('Attachment Content')
@@ -335,11 +373,14 @@ public function toSlack(mixed $notifiable): LegacySlackMessage
class NotificationSlackChannelWithoutOptionalFieldsTestNotification extends Notification
{
+ /**
+ * Get the Slack representation of the notification.
+ */
public function toSlack(mixed $notifiable): LegacySlackMessage
{
return (new LegacySlackMessage)
->content('Content')
- ->attachment(function (SlackAttachment $attachment) {
+ ->attachment(function (SlackAttachment $attachment): void {
$attachment->title('Hypervel', 'https://hypervel.org')
->content('Attachment Content')
->fields([
@@ -351,16 +392,19 @@ public function toSlack(mixed $notifiable): LegacySlackMessage
class NotificationSlackChannelWithAttachmentFieldBuilderTestNotification extends Notification
{
+ /**
+ * Get the Slack representation of the notification.
+ */
public function toSlack(mixed $notifiable): LegacySlackMessage
{
return (new LegacySlackMessage)
->content('Content')
- ->attachment(function (SlackAttachment $attachment) {
+ ->attachment(function (SlackAttachment $attachment): void {
$attachment->title('Hypervel', 'https://hypervel.org')
->content('Attachment Content')
->field('Project', 'Hypervel')
->callbackId('attachment_callbackid')
- ->field(function (SlackAttachmentField $attachmentField) {
+ ->field(function (SlackAttachmentField $attachmentField): void {
$attachmentField
->title('Special powers')
->content('Zonda')
diff --git a/tests/SlackNotificationChannel/NotificationTransportQueueTest.php b/tests/SlackNotificationChannel/NotificationTransportQueueTest.php
index 2b78595011..ed216deac1 100644
--- a/tests/SlackNotificationChannel/NotificationTransportQueueTest.php
+++ b/tests/SlackNotificationChannel/NotificationTransportQueueTest.php
@@ -51,9 +51,8 @@ protected function getPackageProviders(Application $app): array
/**
* Use the real queue that serializes and executes listener jobs.
- * @param mixed $app
*/
- protected function defineEnvironment($app): void
+ protected function defineEnvironment(Application $app): void
{
$app->make('config')->set('queue.default', 'sync');
}
@@ -199,7 +198,7 @@ public function testExistingSymfonyMailFailureConversionRemainsUnchanged(): void
$response = m::mock(SymfonyResponse::class);
$original = new HttpTransportException('Mailer HTTP failure', $response);
$manager = $this->app->make(ChannelManager::class);
- $manager->extend('test-mail', fn () => new NotificationTransportFailingChannel($original));
+ $manager->extend('test-mail', fn (): NotificationTransportFailingChannel => new NotificationTransportFailingChannel($original));
$events = $this->app->make(Dispatcher::class);
$live = null;
$events->listen(NotificationFailed::class, function (NotificationFailed $event) use (&$live): void {
@@ -249,6 +248,9 @@ private function configureTransport(bool $webhook, string $kind): void
class NotificationTransportQueuedListener implements ShouldQueue
{
+ /**
+ * The events received through the queue.
+ */
public array $received = [];
/**
diff --git a/tests/SlackNotificationChannel/Slack/Feature/SlackChannelTest.php b/tests/SlackNotificationChannel/Slack/Feature/SlackChannelTest.php
index 304d3159ca..fc7a5bb13e 100644
--- a/tests/SlackNotificationChannel/Slack/Feature/SlackChannelTest.php
+++ b/tests/SlackNotificationChannel/Slack/Feature/SlackChannelTest.php
@@ -4,6 +4,7 @@
namespace Hypervel\Tests\SlackNotificationChannel\Slack\Feature;
+use GuzzleHttp\Promise\PromiseInterface;
use Hypervel\Http\Client\ConnectionException;
use Hypervel\Http\Client\Factory;
use Hypervel\Http\Client\Request;
@@ -28,7 +29,7 @@ public function testAnHttpFakeInterceptsAnAlreadyResolvedChannel(): void
$response = $channel->send(
new SlackChannelTestNotifiable(SlackRoute::make('#general', 'token')),
- new SlackChannelTestNotification(fn (SlackMessage $message) => $message->text('Content')),
+ new SlackChannelTestNotification(fn (SlackMessage $message): SlackMessage => $message->text('Content')),
);
$this->assertSame('123.456', $response->json('ts'));
@@ -41,7 +42,7 @@ public function testItUsesTheNamedConnectionAndHttpDefaults(array $preset, int $
$http = new Factory;
$http->registerConnection(SlackChannel::CONNECTION, $preset);
$options = [];
- $http->fake(function (Request $request, array $requestOptions) use (&$options, $http) {
+ $http->fake(function (Request $request, array $requestOptions) use (&$options, $http): PromiseInterface {
$options = $requestOptions;
return $http::response(['ok' => true]);
@@ -49,7 +50,7 @@ public function testItUsesTheNamedConnectionAndHttpDefaults(array $preset, int $
(new SlackChannel($http))->send(
new SlackChannelTestNotifiable(SlackRoute::make('#general', 'token')),
- new SlackChannelTestNotification(fn (SlackMessage $message) => $message->text('Content')),
+ new SlackChannelTestNotification(fn (SlackMessage $message): SlackMessage => $message->text('Content')),
);
$this->assertSame(10, $options['connect_timeout']);
@@ -74,7 +75,7 @@ public function testSeparateDeliveriesKeepTheirOwnTokenAndPayload(): void
foreach (['first', 'second'] as $workspace) {
$this->slackChannel->send(
new SlackChannelTestNotifiable(SlackRoute::make("#{$workspace}", "{$workspace}-token")),
- new SlackChannelTestNotification(fn (SlackMessage $message) => $message->text($workspace)),
+ new SlackChannelTestNotification(fn (SlackMessage $message): SlackMessage => $message->text($workspace)),
);
}
@@ -97,7 +98,7 @@ public function testHttpFailuresUseTheHttpClientException(int $status): void
$this->expectException(RequestException::class);
- $this->sendNotification(fn (SlackMessage $message) => $message->text('Content'));
+ $this->sendNotification(fn (SlackMessage $message): SlackMessage => $message->text('Content'));
}
/**
@@ -118,7 +119,7 @@ public function testConnectionFailuresUseTheHttpClientException(): void
$this->expectException(ConnectionException::class);
$this->expectExceptionMessageIsOrContains('Slack is unreachable');
- $this->sendNotification(fn (SlackMessage $message) => $message->text('Content'));
+ $this->sendNotification(fn (SlackMessage $message): SlackMessage => $message->text('Content'));
}
#[DataProvider('successfulResponses')]
@@ -130,7 +131,7 @@ public function testSuccessfulResponsesOnlyRejectAnExplicitFalseOk(array|string
$response = (new SlackChannel($http))->send(
new SlackChannelTestNotifiable(SlackRoute::make('#general', 'token')),
- new SlackChannelTestNotification(fn (SlackMessage $message) => $message->text('Content')),
+ new SlackChannelTestNotification(fn (SlackMessage $message): SlackMessage => $message->text('Content')),
);
$this->assertTrue($response->successful());
@@ -150,7 +151,7 @@ public function testTheRouteNotificationForSlackMethodDescribesTheChannelUsingAS
$this->slackChannel->send(
new SlackChannelTestNotifiable('example-channel'),
- new SlackChannelTestNotification(function (SlackMessage $message) {
+ new SlackChannelTestNotification(function (SlackMessage $message): void {
$message->text('Content')->to('ignored-channel');
}),
);
@@ -167,7 +168,7 @@ public function testTheRouteNotificationForSlackMethodDescribesTheChannelUsingAS
$this->slackChannel->send(
new SlackChannelTestNotifiable(SlackRoute::make('route-set-channel')),
- new SlackChannelTestNotification(function (SlackMessage $message) {
+ new SlackChannelTestNotification(function (SlackMessage $message): void {
$message->text('Content');
}),
);
@@ -184,7 +185,7 @@ public function testTheRouteNotificationForSlackMethodDescribesTheChannelAndToke
$this->slackChannel->send(
new SlackChannelTestNotifiable(SlackRoute::make('route-set-channel', 'route-set-token')),
- new SlackChannelTestNotification(function (SlackMessage $message) {
+ new SlackChannelTestNotification(function (SlackMessage $message): void {
$message->text('Content');
}),
);
@@ -201,7 +202,7 @@ public function testTheRouteNotificationForSlackMethodOnlyDescribesTheTokenUsing
$this->slackChannel->send(
new SlackChannelTestNotifiable(SlackRoute::make(null, 'route-set-token')),
- new SlackChannelTestNotification(function (SlackMessage $message) {
+ new SlackChannelTestNotification(function (SlackMessage $message): void {
$message->text('Content')->to('notification-channel');
}),
);
@@ -212,14 +213,14 @@ public function testTheRouteNotificationForSlackMethodOnlyDescribesTheTokenUsing
], 'route-set-token');
}
- public function testNoRouteUsesTheConfiguredChannelAndToken(): void
+ public function testTheRouteNotificationForSlackMethodDoesNotDescribeAnything(): void
{
config()->set('services.slack.notifications.bot_user_oauth_token', 'config-set-token');
config()->set('services.slack.notifications.channel', 'config-set-channel');
$this->slackChannel->send(
new SlackChannelTestNotifiable,
- new SlackChannelTestNotification(function (SlackMessage $message) {
+ new SlackChannelTestNotification(function (SlackMessage $message): void {
$message->text('Content');
}),
);
@@ -237,7 +238,7 @@ public function testEmptySlackRouteUsesTheConfiguredChannelAndToken(): void
$this->slackChannel->send(
new SlackChannelTestNotifiable(SlackRoute::make()),
- new SlackChannelTestNotification(function (SlackMessage $message) {
+ new SlackChannelTestNotification(function (SlackMessage $message): void {
$message->text('Content');
}),
);
@@ -255,7 +256,7 @@ public function testItPrefersTheNotificationDefinedChannelOverTheConfigDefinedCh
$this->slackChannel->send(
new SlackChannelTestNotifiable,
- new SlackChannelTestNotification(function (SlackMessage $message) {
+ new SlackChannelTestNotification(function (SlackMessage $message): void {
$message->text('Content')->to('notification-channel');
}),
);
@@ -275,7 +276,7 @@ public function testItThrowsAnExceptionWhenTheRouteNotificationForSlackMethodDoe
$this->slackChannel->send(
new SlackChannelTestNotifiable,
- new SlackChannelTestNotification(function (SlackMessage $message) {
+ new SlackChannelTestNotification(function (SlackMessage $message): void {
$message->text('Content');
}),
);
@@ -288,7 +289,7 @@ public function testItThrowsAnExceptionWhenTheRouteNotificationForSlackMethodDoe
$this->slackChannel->send(
new SlackChannelTestNotifiable(SlackRoute::make('hypervel-channel')),
- new SlackChannelTestNotification(function (SlackMessage $message) {
+ new SlackChannelTestNotification(function (SlackMessage $message): void {
$message->text('Content');
}),
);
diff --git a/tests/SlackNotificationChannel/Slack/Feature/SlackMessageTest.php b/tests/SlackNotificationChannel/Slack/Feature/SlackMessageTest.php
index e77c55b9d1..97017ae29f 100644
--- a/tests/SlackNotificationChannel/Slack/Feature/SlackMessageTest.php
+++ b/tests/SlackNotificationChannel/Slack/Feature/SlackMessageTest.php
@@ -11,6 +11,9 @@
use Hypervel\Notifications\Slack\BlockKit\Blocks\SectionBlock;
use Hypervel\Notifications\Slack\SlackChannel;
use Hypervel\Notifications\Slack\SlackMessage;
+use Hypervel\Notifications\Slack\SlackRoute;
+use Hypervel\Tests\SlackNotificationChannel\Slack\Fixtures\SlackChannelTestNotifiable;
+use Hypervel\Tests\SlackNotificationChannel\Slack\Fixtures\SlackChannelTestNotification;
use Hypervel\Tests\SlackNotificationChannel\Slack\TestCase;
use JsonException;
use LogicException;
@@ -18,41 +21,39 @@
class SlackMessageTest extends TestCase
{
- public function testExceptionWhenNoTextOrBlock(): void
+ public function testItThrowsAnExceptionWhenNoTextOrBlockWasProvided(): void
{
$this->expectException(LogicException::class);
$this->expectExceptionMessageIs('Slack messages must contain at least a text message or block.');
- $this->sendNotification(function (SlackMessage $message) {
+ $this->sendNotification(function (SlackMessage $message): void {
$message->to('foo');
});
}
- public function testExceptionWhenTooManyBlocks(): void
+ public function testItThrowsAnExceptionWhenTooManyBlocksAreDefined(): void
{
$this->expectException(LogicException::class);
$this->expectExceptionMessageIs('Slack messages can only contain up to 50 blocks.');
- $this->sendNotification(function (SlackMessage $message) {
+ $this->sendNotification(function (SlackMessage $message): void {
for ($i = 0; $i < 51; ++$i) {
$message->dividerBlock();
}
});
}
- public function testSendBasicMessage(): void
+ public function testItSendsAVeryBasicMessage(): void
{
- $this->sendNotification(function (SlackMessage $message) {
+ $this->sendNotification(function (SlackMessage $message): void {
$message->text('This is a simple Web API text message. See https://api.slack.com/reference/messaging/payload for more information.');
- });
-
- $this->assertNotificationSent([
+ })->assertNotificationSent([
'channel' => '#ghost-talk',
'text' => 'This is a simple Web API text message. See https://api.slack.com/reference/messaging/payload for more information.',
]);
}
- public function testExceptionWithInvalidToken(): void
+ public function testItFailsToSendAMessageWhenTheSlackAppIsNotConfiguredCorrectly(): void
{
$this->expectException(RuntimeException::class);
$this->expectExceptionMessageIs('Slack API call failed with error [invalid_auth].');
@@ -62,60 +63,52 @@ public function testExceptionWithInvalidToken(): void
$http->fake(['*' => $http::response(['ok' => false, 'error' => 'invalid_auth'])]);
$this->slackChannel = new SlackChannel($http);
- $this->sendNotification(function (SlackMessage $message) {
+ $this->sendNotification(function (SlackMessage $message): void {
$message->text('This is a simple Web API text message. See https://api.slack.com/reference/messaging/payload for more information.');
});
}
- public function testSetDefaultChannelForMessage(): void
+ public function testItCanSetTheDefaultChannelForTheMessage(): void
{
- $this->sendNotification(function (SlackMessage $message) {
+ $this->sendNotification(function (SlackMessage $message): void {
$message->text('See https://api.slack.com/methods/chat.postMessage for more information.');
$message->to('#general');
- }, null);
-
- $this->assertNotificationSent([
+ }, null)->assertNotificationSent([
'channel' => '#general',
'text' => 'See https://api.slack.com/methods/chat.postMessage for more information.',
]);
}
- public function testEmojiAsIconForMessage(): void
+ public function testItCanUseAnEmojiAsTheIconForTheMessage(): void
{
- $this->sendNotification(function (SlackMessage $message) {
+ $this->sendNotification(function (SlackMessage $message): void {
$message->text('See https://api.slack.com/methods/chat.postMessage for more information.');
$message->image('emoji-overrides-image-url-automatically-according-to-spec')->emoji(':ghost:');
- });
-
- $this->assertNotificationSent([
+ })->assertNotificationSent([
'channel' => '#ghost-talk',
'text' => 'See https://api.slack.com/methods/chat.postMessage for more information.',
'icon_emoji' => ':ghost:',
]);
}
- public function testImageAsIconForMessage(): void
+ public function testItCanUseAnImageAsTheIconForTheMessage(): void
{
- $this->sendNotification(function (SlackMessage $message) {
+ $this->sendNotification(function (SlackMessage $message): void {
$message->text('See https://api.slack.com/methods/chat.postMessage for more information.');
$message->emoji('auto-clearing-as-to-prefer-image-since-its-called-after')->image('http://lorempixel.com/48/48');
- });
-
- $this->assertNotificationSent([
+ })->assertNotificationSent([
'channel' => '#ghost-talk',
'text' => 'See https://api.slack.com/methods/chat.postMessage for more information.',
'icon_url' => 'http://lorempixel.com/48/48',
]);
}
- public function testCanIncludeMetadata(): void
+ public function testItCanIncludeMetadata(): void
{
- $this->sendNotification(function (SlackMessage $message) {
+ $this->sendNotification(function (SlackMessage $message): void {
$message->text('See https://api.slack.com/methods/chat.postMessage for more information.');
$message->metadata('task_created', ['id' => '11223', 'title' => 'Redesign Homepage']);
- });
-
- $this->assertNotificationSent([
+ })->assertNotificationSent([
'channel' => '#ghost-talk',
'text' => 'See https://api.slack.com/methods/chat.postMessage for more information.',
'metadata' => [
@@ -125,98 +118,84 @@ public function testCanIncludeMetadata(): void
]);
}
- public function testDisableSlackMarkdownParsing(): void
+ public function testItCanDisableSlackMarkdownParsing(): void
{
- $this->sendNotification(function (SlackMessage $message) {
+ $this->sendNotification(function (SlackMessage $message): void {
$message->text('See https://api.slack.com/methods/chat.postMessage for more information.');
$message->disableMarkdownParsing();
- });
-
- $this->assertNotificationSent([
+ })->assertNotificationSent([
'channel' => '#ghost-talk',
'text' => 'See https://api.slack.com/methods/chat.postMessage for more information.',
'mrkdwn' => false,
]);
}
- public function testUnfurlLink(): void
+ public function testItCanUnfurlLinks(): void
{
- $this->sendNotification(function (SlackMessage $message) {
+ $this->sendNotification(function (SlackMessage $message): void {
$message->text('See https://api.slack.com/methods/chat.postMessage for more information.');
$message->unfurlLinks();
- });
-
- $this->assertNotificationSent([
+ })->assertNotificationSent([
'channel' => '#ghost-talk',
'text' => 'See https://api.slack.com/methods/chat.postMessage for more information.',
'unfurl_links' => true,
]);
}
- public function testUnfurlMedia(): void
+ public function testItCanUnfurlMedia(): void
{
- $this->sendNotification(function (SlackMessage $message) {
+ $this->sendNotification(function (SlackMessage $message): void {
$message->text('See https://api.slack.com/methods/chat.postMessage for more information.');
$message->unfurlMedia();
- });
-
- $this->assertNotificationSent([
+ })->assertNotificationSent([
'channel' => '#ghost-talk',
'text' => 'See https://api.slack.com/methods/chat.postMessage for more information.',
'unfurl_media' => true,
]);
}
- public function testCanReplyAsThread(): void
+ public function testItCanReplyAsThread(): void
{
- $this->sendNotification(function (SlackMessage $message) {
+ $this->sendNotification(function (SlackMessage $message): void {
$message->text('See https://api.slack.com/methods/chat.postMessage for more information.');
$message->threadTimestamp('123456.7890');
- });
-
- $this->assertNotificationSent([
+ })->assertNotificationSent([
'channel' => '#ghost-talk',
'text' => 'See https://api.slack.com/methods/chat.postMessage for more information.',
'thread_ts' => '123456.7890',
]);
}
- public function testSendThreadedReplyAsBroadcastReference(): void
+ public function testItCanSendThreadedReplyAsBroadcastReference(): void
{
- $this->sendNotification(function (SlackMessage $message) {
+ $this->sendNotification(function (SlackMessage $message): void {
$message->text('See https://api.slack.com/methods/chat.postMessage for more information.');
$message->broadcastReply(true);
- });
-
- $this->assertNotificationSent([
+ })->assertNotificationSent([
'channel' => '#ghost-talk',
'text' => 'See https://api.slack.com/methods/chat.postMessage for more information.',
'reply_broadcast' => true,
]);
}
- public function testSetBotUserName(): void
+ public function testItCanSetTheBotUserName(): void
{
- $this->sendNotification(function (SlackMessage $message) {
+ $this->sendNotification(function (SlackMessage $message): void {
$message->text('See https://api.slack.com/methods/chat.postMessage for more information.');
$message->username('larabot');
- });
-
- $this->assertNotificationSent([
+ })->assertNotificationSent([
'channel' => '#ghost-talk',
'text' => 'See https://api.slack.com/methods/chat.postMessage for more information.',
'username' => 'larabot',
]);
}
- public function testContainsBothBlocksAndFallbackTextInNotifications(): void
+ public function testItContainsBothBlocksAndAFallbackTextUsedInNotificationsOnly(): void
{
- $this->sendNotification(function (SlackMessage $message) {
+ $this->sendNotification(function (SlackMessage $message): void {
$message->text('This is now a fallback text used in notifications. See https://api.slack.com/methods/chat.postMessage for more information.');
$message->dividerBlock();
- });
-
- $this->assertNotificationSent([
+ })->assertNotificationSent([
'channel' => '#ghost-talk',
'text' => 'This is now a fallback text used in notifications. See https://api.slack.com/methods/chat.postMessage for more information.',
'blocks' => [
@@ -227,15 +206,13 @@ public function testContainsBothBlocksAndFallbackTextInNotifications(): void
]);
}
- public function testContainActionBlocks(): void
+ public function testItCanContainActionBlocks(): void
{
- $this->sendNotification(function (SlackMessage $message) {
- $message->actionsBlock(function (ActionsBlock $actions) {
+ $this->sendNotification(function (SlackMessage $message): void {
+ $message->actionsBlock(function (ActionsBlock $actions): void {
$actions->button('Cancel')->value('cancel')->id('button_1');
});
- });
-
- $this->assertNotificationSent([
+ })->assertNotificationSent([
'channel' => '#ghost-talk',
'blocks' => [
[
@@ -256,15 +233,13 @@ public function testContainActionBlocks(): void
]);
}
- public function testContainContextBlocks(): void
+ public function testItCanContainContextBlocks(): void
{
- $this->sendNotification(function (SlackMessage $message) {
- $message->contextBlock(function (ContextBlock $context) {
+ $this->sendNotification(function (SlackMessage $message): void {
+ $message->contextBlock(function (ContextBlock $context): void {
$context->image('https://image.freepik.com/free-photo/red-drawing-pin_1156-445.jpg')->alt('images');
});
- });
-
- $this->assertNotificationSent([
+ })->assertNotificationSent([
'channel' => '#ghost-talk',
'blocks' => [
[
@@ -281,13 +256,11 @@ public function testContainContextBlocks(): void
]);
}
- public function testContainDividerBlocks(): void
+ public function testItCanContainDividerBlocks(): void
{
- $this->sendNotification(function (SlackMessage $message) {
+ $this->sendNotification(function (SlackMessage $message): void {
$message->dividerBlock();
- });
-
- $this->assertNotificationSent([
+ })->assertNotificationSent([
'channel' => '#ghost-talk',
'blocks' => [
[
@@ -297,13 +270,11 @@ public function testContainDividerBlocks(): void
]);
}
- public function testContainHeaderBlocks(): void
+ public function testItCanContainHeaderBlocks(): void
{
- $this->sendNotification(function (SlackMessage $message) {
+ $this->sendNotification(function (SlackMessage $message): void {
$message->headerBlock('Budget Performance');
- });
-
- $this->assertNotificationSent([
+ })->assertNotificationSent([
'channel' => '#ghost-talk',
'blocks' => [
[
@@ -317,15 +288,13 @@ public function testContainHeaderBlocks(): void
]);
}
- public function testContainImageBlocks(): void
+ public function testItCanContainImageBlocks(): void
{
- $this->sendNotification(function (SlackMessage $message) {
- $message->imageBlock('http://placekitten.com/500/500', function (ImageBlock $imageBlock) {
+ $this->sendNotification(function (SlackMessage $message): void {
+ $message->imageBlock('http://placekitten.com/500/500', function (ImageBlock $imageBlock): void {
$imageBlock->alt('An incredibly cute kitten.');
});
- });
-
- $this->assertNotificationSent([
+ })->assertNotificationSent([
'channel' => '#ghost-talk',
'blocks' => [
[
@@ -337,15 +306,13 @@ public function testContainImageBlocks(): void
]);
}
- public function testContainSectionBlocks(): void
+ public function testItCanContainSectionBlocks(): void
{
- $this->sendNotification(function (SlackMessage $message) {
- $message->sectionBlock(function (SectionBlock $sectionBlock) {
+ $this->sendNotification(function (SlackMessage $message): void {
+ $message->sectionBlock(function (SectionBlock $sectionBlock): void {
$sectionBlock->text('A message *with some bold text* and _some italicized text_.')->markdown();
});
- });
-
- $this->assertNotificationSent([
+ })->assertNotificationSent([
'channel' => '#ghost-talk',
'blocks' => [
[
@@ -359,29 +326,27 @@ public function testContainSectionBlocks(): void
]);
}
- public function testAddBlocksConditionally(): void
+ public function testItCanAddBlocksConditionally(): void
{
- $this->sendNotification(function (SlackMessage $message) {
- $message->when(true, function (SlackMessage $message) {
- $message->sectionBlock(function (SectionBlock $sectionBlock) {
+ $this->sendNotification(function (SlackMessage $message): void {
+ $message->when(true, function (SlackMessage $message): void {
+ $message->sectionBlock(function (SectionBlock $sectionBlock): void {
$sectionBlock->text('I *will* be included.')->markdown();
});
- })->when(false, function (SlackMessage $message) {
- $message->sectionBlock(function (SectionBlock $sectionBlock) {
+ })->when(false, function (SlackMessage $message): void {
+ $message->sectionBlock(function (SectionBlock $sectionBlock): void {
$sectionBlock->text("I *won't* be included.")->markdown();
});
- })->when(false, function (SlackMessage $message) {
- $message->sectionBlock(function (SectionBlock $sectionBlock) {
+ })->when(false, function (SlackMessage $message): void {
+ $message->sectionBlock(function (SectionBlock $sectionBlock): void {
$sectionBlock->text("I'm *not* included either...")->markdown();
});
- }, function (SlackMessage $message) {
- $message->sectionBlock(function (SectionBlock $sectionBlock) {
+ }, function (SlackMessage $message): void {
+ $message->sectionBlock(function (SectionBlock $sectionBlock): void {
$sectionBlock->text('But I *will* be included!')->markdown();
});
});
- });
-
- $this->assertNotificationSent([
+ })->assertNotificationSent([
'channel' => '#ghost-talk',
'blocks' => [
[
@@ -402,17 +367,15 @@ public function testAddBlocksConditionally(): void
]);
}
- public function testBlocksInTheOrder(): void
+ public function testItSubmitsBlocksInTheOrderTheyWereDefined(): void
{
- $this->sendNotification(function (SlackMessage $message) {
+ $this->sendNotification(function (SlackMessage $message): void {
$message->headerBlock('Budget Performance');
- $message->sectionBlock(function (SectionBlock $sectionBlock) {
+ $message->sectionBlock(function (SectionBlock $sectionBlock): void {
$sectionBlock->text('A message *with some bold text* and _some italicized text_.')->markdown();
});
$message->headerBlock('Market Performance');
- });
-
- $this->assertNotificationSent([
+ })->assertNotificationSent([
'channel' => '#ghost-talk',
'blocks' => [
[
@@ -440,42 +403,9 @@ public function testBlocksInTheOrder(): void
]);
}
- public function testCopiedBlockKitTemplate(): void
+ public function testItCanUseCopiedBlockKitTemplate(): void
{
- $payload = [
- 'channel' => '#ghost-talk',
- 'blocks' => [
- [
- 'type' => 'header',
- 'text' => [
- 'type' => 'plain_text',
- 'text' => 'This is a header block',
- 'emoji' => true,
- ],
- ],
- [
- 'type' => 'context',
- 'elements' => [
- [
- 'type' => 'image',
- 'image_url' => 'https://pbs.twimg.com/profile_images/625633822235693056/lNGUneLX_400x400.jpg',
- 'alt_text' => 'cute cat',
- ],
- [
- 'type' => 'mrkdwn',
- 'text' => '*Cat* has approved this message.',
- ],
- ],
- ],
- [
- 'type' => 'image',
- 'image_url' => 'https://assets3.thrillist.com/v1/image/1682388/size/tl-horizontal_main.jpg',
- 'alt_text' => 'delicious tacos',
- ],
- ],
- ];
-
- $this->sendNotification(function (SlackMessage $message) {
+ $this->sendNotification(function (SlackMessage $message): void {
$message->usingBlockKitTemplate(<<<'JSON'
{
"blocks": [
@@ -509,14 +439,7 @@ public function testCopiedBlockKitTemplate(): void
]
}
JSON);
- });
-
- $this->assertNotificationSent($payload);
- }
-
- public function testCombinedBlockKitTemplateAndBlockContractInOrder(): void
- {
- $payload = [
+ })->assertNotificationSent([
'channel' => '#ghost-talk',
'blocks' => [
[
@@ -528,7 +451,18 @@ public function testCombinedBlockKitTemplateAndBlockContractInOrder(): void
],
],
[
- 'type' => 'divider',
+ 'type' => 'context',
+ 'elements' => [
+ [
+ 'type' => 'image',
+ 'image_url' => 'https://pbs.twimg.com/profile_images/625633822235693056/lNGUneLX_400x400.jpg',
+ 'alt_text' => 'cute cat',
+ ],
+ [
+ 'type' => 'mrkdwn',
+ 'text' => '*Cat* has approved this message.',
+ ],
+ ],
],
[
'type' => 'image',
@@ -536,9 +470,12 @@ public function testCombinedBlockKitTemplateAndBlockContractInOrder(): void
'alt_text' => 'delicious tacos',
],
],
- ];
+ ]);
+ }
- $this->sendNotification(function (SlackMessage $message) {
+ public function testItCanCombinedBlockKitTemplateAndBlockContractInOrder(): void
+ {
+ $this->sendNotification(function (SlackMessage $message): void {
$message->usingBlockKitTemplate(<<<'JSON'
{
"blocks": [
@@ -567,27 +504,53 @@ public function testCombinedBlockKitTemplateAndBlockContractInOrder(): void
]
}
JSON);
- });
-
- $this->assertNotificationSent($payload);
+ })->assertNotificationSent([
+ 'channel' => '#ghost-talk',
+ 'blocks' => [
+ [
+ 'type' => 'header',
+ 'text' => [
+ 'type' => 'plain_text',
+ 'text' => 'This is a header block',
+ 'emoji' => true,
+ ],
+ ],
+ [
+ 'type' => 'divider',
+ ],
+ [
+ 'type' => 'image',
+ 'image_url' => 'https://assets3.thrillist.com/v1/image/1682388/size/tl-horizontal_main.jpg',
+ 'alt_text' => 'delicious tacos',
+ ],
+ ],
+ ]);
}
- public function testCanReturnABlockKitBuilderUrl(): void
+ public function testItCanReturnAnBlockKitBuilderUrl(): void
{
- $message = (new SlackMessage)
- ->username('hyperbot')
- ->to('#ghost-talk')
- ->headerBlock('Budget Performance')
- ->sectionBlock(function (SectionBlock $sectionBlock) {
- $sectionBlock->text('A message *with some bold text* and _some italicized text_.')->markdown();
- });
-
- $expectedUrl = 'https://app.slack.com/block-kit-builder#' . rawurlencode(
- '{"blocks":[{"type":"header","text":{"type":"plain_text","text":"Budget Performance"}},'
- . '{"type":"section","text":{"type":"mrkdwn","text":"A message *with some bold text* and _some italicized text_."}}]}'
+ $message = (new SlackChannelTestNotification(function (SlackMessage $message): void {
+ $message
+ ->username('larabot')
+ ->to('#ghost-talk')
+ ->headerBlock('Budget Performance')
+ ->sectionBlock(function (SectionBlock $sectionBlock): void {
+ $sectionBlock->text('A message *with some bold text* and _some italicized text_.')->markdown();
+ });
+ }))->toSlack(
+ new SlackChannelTestNotifiable(new SlackRoute('#ghost-talk', 'fake-token'))
);
- $this->assertSame($expectedUrl, $message->toBlockKitBuilderUrl());
+ $returnedUrl = $message->toBlockKitBuilderUrl();
+ $expectedUrl = 'https://app.slack.com/block-kit-builder#'
+ . rawurlencode('{"blocks":[{"type":"header","text":{"type":"plain_text","text":"Budget Performance"}},{"type":"section","text":{"type":"mrkdwn","text":"A message *with some bold text* and _some italicized text_."}}]}');
+
+ $this->assertStringNotContainsStringIgnoringCase('username', $returnedUrl);
+ $this->assertStringNotContainsStringIgnoringCase('larabot', $returnedUrl);
+ $this->assertStringNotContainsStringIgnoringCase('channel', $returnedUrl);
+ $this->assertStringNotContainsStringIgnoringCase('#ghost-talk', $returnedUrl);
+
+ $this->assertSame($expectedUrl, $returnedUrl);
}
public function testBlockKitBuilderUrlReportsJsonEncodingFailures(): void
diff --git a/tests/SlackNotificationChannel/Slack/Fixtures/SlackChannelTestNotifiable.php b/tests/SlackNotificationChannel/Slack/Fixtures/SlackChannelTestNotifiable.php
index 8ec003aa9c..dc895eef47 100644
--- a/tests/SlackNotificationChannel/Slack/Fixtures/SlackChannelTestNotifiable.php
+++ b/tests/SlackNotificationChannel/Slack/Fixtures/SlackChannelTestNotifiable.php
@@ -6,19 +6,24 @@
use Hypervel\Notifications\Notifiable;
use Hypervel\Notifications\Slack\SlackRoute;
+use Psr\Http\Message\UriInterface;
class SlackChannelTestNotifiable
{
use Notifiable;
- protected SlackRoute|string|null $route;
-
- public function __construct(SlackRoute|string|null $route = null)
- {
- $this->route = $route;
+ /**
+ * Create a notifiable with the given Slack route.
+ */
+ public function __construct(
+ protected SlackRoute|UriInterface|string|false|null $route = null
+ ) {
}
- public function routeNotificationForSlack(): SlackRoute|string|null
+ /**
+ * Get the notification routing information for the Slack channel.
+ */
+ public function routeNotificationForSlack(): SlackRoute|UriInterface|string|false|null
{
return $this->route;
}
diff --git a/tests/SlackNotificationChannel/Slack/Fixtures/SlackChannelTestNotification.php b/tests/SlackNotificationChannel/Slack/Fixtures/SlackChannelTestNotification.php
index 9215d06aa8..0cdfc73077 100644
--- a/tests/SlackNotificationChannel/Slack/Fixtures/SlackChannelTestNotification.php
+++ b/tests/SlackNotificationChannel/Slack/Fixtures/SlackChannelTestNotification.php
@@ -10,14 +10,23 @@
class SlackChannelTestNotification extends Notification
{
+ /**
+ * The callback that builds the Slack message.
+ */
private Closure $callback;
+ /**
+ * Create a notification with the given message callback.
+ */
public function __construct(?Closure $callback = null)
{
- $this->callback = $callback ?? function () {
+ $this->callback = $callback ?? function (): void {
};
}
+ /**
+ * Get the Slack representation of the notification.
+ */
public function toSlack(mixed $notifiable): SlackMessage
{
return tap(new SlackMessage, $this->callback);
diff --git a/tests/SlackNotificationChannel/Slack/TestCase.php b/tests/SlackNotificationChannel/Slack/TestCase.php
index b3d62b9d5f..65740d9a94 100644
--- a/tests/SlackNotificationChannel/Slack/TestCase.php
+++ b/tests/SlackNotificationChannel/Slack/TestCase.php
@@ -18,10 +18,16 @@
abstract class TestCase extends BaseTestCase
{
- protected SlackChannel $slackChannel;
-
+ /**
+ * The HTTP client factory.
+ */
protected Factory $http;
+ /**
+ * The Slack Web API channel.
+ */
+ protected SlackChannel $slackChannel;
+
/**
* Get the package providers.
*/
@@ -64,7 +70,8 @@ protected function assertNotificationSent(array $payload, string $token = 'fake-
return $request->url() === 'https://slack.com/api/chat.postMessage'
&& $request->method() === 'POST'
&& $request->data() === $payload
- && $request->hasHeader('Authorization', "Bearer {$token}");
+ && $request->hasHeader('Authorization', "Bearer {$token}")
+ && $request->hasHeader('Content-Type', 'application/json');
});
}
}
diff --git a/tests/SlackNotificationChannel/SlackChannelServiceProviderTest.php b/tests/SlackNotificationChannel/SlackChannelServiceProviderTest.php
index e2b0d3a8e7..f93e407625 100644
--- a/tests/SlackNotificationChannel/SlackChannelServiceProviderTest.php
+++ b/tests/SlackNotificationChannel/SlackChannelServiceProviderTest.php
@@ -4,10 +4,12 @@
namespace Hypervel\Tests\SlackNotificationChannel;
+use GuzzleHttp\Client as HttpClient;
use Hypervel\Config\Repository;
use Hypervel\Foundation\Application;
use Hypervel\Http\Client\Factory;
use Hypervel\Notifications\ChannelManager;
+use Hypervel\Notifications\Channels\SlackWebhookChannel;
use Hypervel\Notifications\NotificationServiceProvider;
use Hypervel\Notifications\Slack\SlackChannel;
use Hypervel\Notifications\SlackChannelServiceProvider;
@@ -46,6 +48,35 @@ public static function managerResolution(): array
return [[false], [true]];
}
+ public function testTheWebhookChannelUsesBoundedHttpTimeouts(): void
+ {
+ $app = new Application;
+ Facade::setFacadeApplication($app);
+ (new SlackChannelServiceProvider($app))->register();
+
+ $client = (fn (): HttpClient => $this->http)->call($app->make(SlackWebhookChannel::class));
+
+ $this->assertSame(10, $client->getConfig('connect_timeout'));
+ $this->assertSame(30, $client->getConfig('timeout'));
+ }
+
+ public function testApplicationsCanReplaceTheWebhookHttpClient(): void
+ {
+ $app = new Application;
+ Facade::setFacadeApplication($app);
+ (new SlackChannelServiceProvider($app))->register();
+
+ $client = new HttpClient;
+ $app->when(SlackWebhookChannel::class)
+ ->needs(HttpClient::class)
+ ->give(fn (): HttpClient => $client);
+
+ $this->assertSame(
+ $client,
+ (fn (): HttpClient => $this->http)->call($app->make(SlackWebhookChannel::class)),
+ );
+ }
+
public function testItRegistersTheSlackHttpConnection(): void
{
$http = new Factory;
diff --git a/tests/SlackNotificationChannel/SlackNotificationRouterChannelTest.php b/tests/SlackNotificationChannel/SlackNotificationRouterChannelTest.php
index 2fc8790a84..75879332ad 100644
--- a/tests/SlackNotificationChannel/SlackNotificationRouterChannelTest.php
+++ b/tests/SlackNotificationChannel/SlackNotificationRouterChannelTest.php
@@ -11,8 +11,8 @@
use Hypervel\Notifications\Channels\SlackWebhookChannel;
use Hypervel\Notifications\Notification;
use Hypervel\Notifications\Slack\SlackChannel as SlackWebApiChannel;
-use Hypervel\Notifications\Slack\SlackRoute;
use Hypervel\Notifications\SlackNotificationRouterChannel;
+use Hypervel\Tests\SlackNotificationChannel\Slack\Fixtures\SlackChannelTestNotifiable;
use Hypervel\Tests\SlackNotificationChannel\Slack\Fixtures\SlackChannelTestNotification;
use Hypervel\Tests\TestCase;
use Mockery as m;
@@ -24,7 +24,7 @@ public function testItRoutesTheNotificationToTheWebhookChannelWhenTheNotifiableR
$app = new Container;
$webhook = m::mock(SlackWebhookChannel::class);
$webApi = m::mock(SlackWebApiChannel::class);
- $webhook->shouldReceive('send')->once()->withArgs(function ($notifiable, $notification) {
+ $webhook->shouldReceive('send')->once()->withArgs(function (SlackChannelTestNotifiable $notifiable, Notification $notification): bool {
return $notifiable->routeNotificationFor('slack', $notification) === 'http://example.com';
})->andReturn(new Response);
$webApi->shouldNotReceive('send');
@@ -33,7 +33,7 @@ public function testItRoutesTheNotificationToTheWebhookChannelWhenTheNotifiableR
$channel = new SlackNotificationRouterChannel($app);
- $channel->send(new SlackNotificationRouterTestNotifiable('http://example.com'), new SlackChannelTestNotification);
+ $channel->send(new SlackChannelTestNotifiable('http://example.com'), new SlackChannelTestNotification);
}
public function testItRoutesTheNotificationToTheWebhookChannelWhenTheNotifiableRouteIsAPsrUrlInstance(): void
@@ -41,7 +41,7 @@ public function testItRoutesTheNotificationToTheWebhookChannelWhenTheNotifiableR
$app = new Container;
$webhook = m::mock(SlackWebhookChannel::class);
$webApi = m::mock(SlackWebApiChannel::class);
- $webhook->shouldReceive('send')->once()->withArgs(function ($notifiable, $notification) {
+ $webhook->shouldReceive('send')->once()->withArgs(function (SlackChannelTestNotifiable $notifiable, Notification $notification): bool {
return $notifiable->routeNotificationFor('slack', $notification) instanceof Uri;
})->andReturn(new Response);
$webApi->shouldNotReceive('send');
@@ -50,7 +50,7 @@ public function testItRoutesTheNotificationToTheWebhookChannelWhenTheNotifiableR
$channel = new SlackNotificationRouterChannel($app);
- $channel->send(new SlackNotificationRouterTestNotifiable(new Uri('foo')), new SlackChannelTestNotification);
+ $channel->send(new SlackChannelTestNotifiable(new Uri('foo')), new SlackChannelTestNotification);
}
public function testItRoutesTheNotificationToTheWebApiChannelWhenTheNotifiableRouteIsNotAnUrl(): void
@@ -59,8 +59,8 @@ public function testItRoutesTheNotificationToTheWebApiChannelWhenTheNotifiableRo
$webhook = m::mock(SlackWebhookChannel::class);
$webApi = m::mock(SlackWebApiChannel::class);
$webhook->shouldNotReceive('send');
- $webApi->shouldReceive('send')->once()->withArgs(function ($notifiable, $notification) {
- return $notifiable->routeNotificationFor('slack', $notification) instanceof SlackRoute;
+ $webApi->shouldReceive('send')->once()->withArgs(function (SlackChannelTestNotifiable $notifiable, Notification $notification): bool {
+ return $notifiable->routeNotificationFor('slack', $notification) === '#general';
})->andReturn($response = new HttpResponse(new Response));
$app->instance(SlackWebhookChannel::class, $webhook);
$app->instance(SlackWebApiChannel::class, $webApi);
@@ -68,7 +68,7 @@ public function testItRoutesTheNotificationToTheWebApiChannelWhenTheNotifiableRo
$channel = new SlackNotificationRouterChannel($app);
$this->assertSame($response, $channel->send(
- new SlackNotificationRouterTestNotifiable(SlackRoute::make('#general')),
+ new SlackChannelTestNotifiable('#general'),
new SlackChannelTestNotification,
));
}
@@ -86,27 +86,8 @@ public function testItStopsSendingWhenTheNotifiableRouteIsFalse(): void
$channel = new SlackNotificationRouterChannel($app);
$this->assertNull($channel->send(
- new SlackNotificationRouterTestNotifiable(false),
+ new SlackChannelTestNotifiable(false),
new SlackChannelTestNotification,
));
}
}
-
-class SlackNotificationRouterTestNotifiable
-{
- /**
- * Create a notifiable with the given Slack route.
- */
- public function __construct(
- private readonly mixed $route,
- ) {
- }
-
- /**
- * Get the notification route.
- */
- public function routeNotificationFor(string $driver, ?Notification $notification = null): mixed
- {
- return $this->route;
- }
-}
From 5369de3a031505a1b2d470b30d9c9cca0cd2390a Mon Sep 17 00:00:00 2001
From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com>
Date: Thu, 8 Oct 2026 14:25:50 +0000
Subject: [PATCH 09/18] Track relevant Hyperf runtime changes
Add Engine followed by the Hyperf monorepo with the owner-selected November 1, 2025 history boundary. Record selective runtime review scope and source mappings while preserving Hypervel architecture and Laravel-style APIs. Consult Engine Contract only when an Engine change requires it.
---
docs/upstream-sync/sync.yaml | 44 ++++++++++++++++++++++++++++++++++++
1 file changed, 44 insertions(+)
diff --git a/docs/upstream-sync/sync.yaml b/docs/upstream-sync/sync.yaml
index 4af25b190d..79a1be4159 100644
--- a/docs/upstream-sync/sync.yaml
+++ b/docs/upstream-sync/sync.yaml
@@ -229,6 +229,50 @@ Sammyjo20/saloon-docs:
sync_date: '2026-10-07'
notes: Shared documentation for the unified src/saloon package and all four plugin entries above. Reconcile applicable core and plugin pages into the existing sections of the single src/docs/saloon.md rather than separate plugin guides, keeping Hypervel's additions and checking examples against current source, in Laravel-style prose matching the guide. The v3 branch includes v4 guidance. Installation commands, supported versions, upgrade guides, release notes, showcase, tutorials, the book and the third-party Lawman and SDK generator pages have no Hypervel equivalent; check them only for behavior the guide must still describe.
+hyperf/engine:
+ branch: master
+ # Owner-selected history boundary before 2025-11-01, not a historical parity claim.
+ checked_through: 6595d2659ce7ebb940b8740a00ecd199128398f0
+ last_reviewed_pr: null
+ sync_date: null
+ notes: >-
+ Selective runtime tracking for Hypervel's independently maintained Engine;
+ review before hyperf/hyperf. Source maps to src/engine and interfaces to
+ src/contracts/src/Engine. Consult hyperf/engine-contract when an Engine change
+ depends on its contracts; it is not a separate tracking entry. Assess changes
+ for applicable correctness, security, coroutine isolation, blocking,
+ cancellation, resource cleanup, pooling, Swoole compatibility and performance
+ improvements. Verify applicability against current Hypervel source, adapt
+ useful changes and include relevant regression coverage. Preserve Hypervel's
+ architecture, Laravel-style APIs and enhancements; do not pursue Hyperf API
+ or full test-suite parity, restore removed Hyperf infrastructure or add Swow
+ support. HTTP server and response-emitter changes must be assessed against
+ src/http-server's Swoole bridges rather than restoring Engine's portability
+ layer. Follow affected consumers beyond these mappings when necessary.
+
+hyperf/hyperf:
+ branch: master
+ # Owner-selected history boundary before 2025-11-01, not a historical parity claim.
+ checked_through: ded8b966ecf78590e8201a47a56c635f8df99e47
+ last_reviewed_pr: null
+ sync_date: null
+ notes: >-
+ Selective runtime tracking under the same applicability rules as hyperf/engine
+ above, not a framework merge target. Review the monorepo, not its subtree-split
+ mirrors. Upstream src/{context,coroutine,coordinator,server,signal,websocket-server,watcher}
+ map to the same Hypervel packages; pool maps to connection-pool, with shared
+ pooling issues also assessed against object-pool; framework bootstrap/events
+ and logging map to core; process maps to server-process; http-server and
+ http-message transport/lifecycle changes map to http-server's bridges; di AOP
+ and proxy generation map to di, not Hyperf's container; contract maps to the
+ corresponding runtime interfaces in contracts. Assess db-connection and
+ database driver changes against database's connection/pooling code, redis
+ against redis's connection/pooling code, grpc/grpc-client/grpc-server and
+ http2-client against grpc and Engine HTTP/2, and guzzle transport issues against
+ http and engine's different transports. These are applicability mappings,
+ not instructions to restore upstream implementations. Laravel remains the
+ primary API and implementation reference for Laravel-ported components.
+
getsentry/sentry-laravel:
branch: master
checked_through: null
From f40df6c019a2d4ec8e967ba13ea49909e353c876 Mon Sep 17 00:00:00 2001
From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com>
Date: Sat, 10 Oct 2026 06:10:08 +0000
Subject: [PATCH 10/18] Keep a test's own failure when its children reach the
time limit
When a coroutine test threw while child coroutines it started were still
blocked, the runner waited for those children until the test's time
limit and then reported only "This test was aborted after N seconds".
The assertion failure or error that caused the problem was lost.
If the test method returned or threw before the deadline and its
children are what reached the limit, the runner now rethrows the test's
own exception. Failures and errors are reported with their original
message. An exception the test expected would otherwise make PHPUnit
pass the test, so the runner keeps it and a post-condition fails the
test, naming the leftover children with the expected exception as the
cause. Skipped and incomplete tests never reach post-conditions, so
they keep the time-limit abort. Passing tests that leave children
running still time out, and children are not cancelled earlier than
before.
The time-limit fixture covers failures, errors, expected exceptions
and assertion failures, and skipped and incomplete tests, each with
children still running at the limit, and checks that tearDown() still
runs. Expected-exception tests whose children finish in time still
pass, with the children completing normally.
Verified with TimeLimitTest, composer lint:fix, composer analyse and
the full composer test:parallel suite, plus mutation checks that
disabling the rethrow or the skipped/incomplete exclusion fails the
new cases.
---
.../Testing/Concerns/RunTestsInCoroutine.php | 49 ++++++++-
.../PHPUnit/Fixtures/TimeLimitFixture.php | 104 +++++++++++++++++-
tests/Testing/PHPUnit/TimeLimitTest.php | 93 +++++++++++++++-
3 files changed, 236 insertions(+), 10 deletions(-)
diff --git a/src/foundation/src/Testing/Concerns/RunTestsInCoroutine.php b/src/foundation/src/Testing/Concerns/RunTestsInCoroutine.php
index 3fb3ec007d..2d8c0e40a3 100644
--- a/src/foundation/src/Testing/Concerns/RunTestsInCoroutine.php
+++ b/src/foundation/src/Testing/Concerns/RunTestsInCoroutine.php
@@ -10,6 +10,10 @@
use Hypervel\Database\DatabaseTransactionsManager;
use Hypervel\Database\Eloquent\Model;
use Hypervel\Engine\Coroutine as EngineCoroutine;
+use PHPUnit\Framework\AssertionFailedError;
+use PHPUnit\Framework\Attributes\PostCondition;
+use PHPUnit\Framework\IncompleteTest;
+use PHPUnit\Framework\SkippedTest;
use SebastianBergmann\Invoker\TimeoutException;
use Swoole\Coroutine;
use Swoole\Coroutine\Channel;
@@ -24,6 +28,11 @@ trait RunTestsInCoroutine
protected bool $copyNonCoroutineContext = true;
+ /**
+ * The exception the test threw before its child coroutines reached the time limit.
+ */
+ protected ?Throwable $exceptionOutlivedByChildCoroutines = null;
+
/**
* Invoke the test method inside a Swoole coroutine container.
*
@@ -43,6 +52,8 @@ protected function invokeTestMethod(string $methodName, array $testArguments): m
$testResult = null;
$exception = null;
$timeoutException = null;
+ $rootCompleted = false;
+ $this->exceptionOutlivedByChildCoroutines = null;
$capture = static function (callable $callback) use (&$exception): void {
try {
@@ -52,11 +63,11 @@ protected function invokeTestMethod(string $methodName, array $testArguments): m
}
};
- run(function () use (&$testResult, &$exception, &$timeoutException, $capture, $methodName, $testArguments): void {
+ run(function () use (&$testResult, &$exception, &$timeoutException, &$rootCompleted, $capture, $methodName, $testArguments): void {
$shouldBootFramework = false;
try {
- $this->enforceCoroutineTestTimeLimit($timeoutException);
+ $this->enforceCoroutineTestTimeLimit($timeoutException, $rootCompleted);
$this->clearNonCoroutineTransactionContext();
if ($this->copyNonCoroutineContext) {
@@ -86,6 +97,19 @@ protected function invokeTestMethod(string $methodName, array $testArguments): m
});
if ($timeoutException !== null) {
+ // Children outliving a test that threw must not hide its exception. PHPUnit still matches it against
+ // the test's expected exception, and a matched one fails in the post-condition instead. Skipped and
+ // incomplete tests never reach the post-condition, so they keep the timeout.
+ if ($rootCompleted
+ && $exception !== null
+ && ! $exception instanceof SkippedTest
+ && ! $exception instanceof IncompleteTest
+ ) {
+ $this->exceptionOutlivedByChildCoroutines = $exception;
+
+ throw $exception;
+ }
+
throw $timeoutException;
}
@@ -99,7 +123,7 @@ protected function invokeTestMethod(string $methodName, array $testArguments): m
/**
* Enforce PHPUnit's deadline while the test or its children are suspended.
*/
- protected function enforceCoroutineTestTimeLimit(?TimeoutException &$timeoutException): void
+ protected function enforceCoroutineTestTimeLimit(?TimeoutException &$timeoutException, bool &$rootCompleted): void
{
if (! function_exists('pcntl_alarm')) {
return;
@@ -122,7 +146,7 @@ protected function enforceCoroutineTestTimeLimit(?TimeoutException &$timeoutExce
$completed->push(true);
});
- Coroutine::create(static function () use ($root, $deadline, $completed, &$timeoutException): void {
+ Coroutine::create(static function () use ($root, $deadline, $completed, &$timeoutException, &$rootCompleted): void {
$current = Coroutine::getCid();
try {
@@ -130,6 +154,8 @@ protected function enforceCoroutineTestTimeLimit(?TimeoutException &$timeoutExce
// Native channel and sleep timers survive Timer::clearAll() cleanup.
if ($remaining > 0 && $completed->pop($remaining)) {
+ $rootCompleted = true;
+
while (true) {
$coroutines = array_filter(
iterator_to_array(Coroutine::list()),
@@ -168,6 +194,21 @@ protected function enforceCoroutineTestTimeLimit(?TimeoutException &$timeoutExce
});
}
+ /**
+ * Fail a test whose expected exception was followed by child coroutines reaching the time limit.
+ */
+ #[PostCondition]
+ protected function assertChildCoroutinesFinishedBeforeTimeLimit(): void
+ {
+ if ($this->exceptionOutlivedByChildCoroutines !== null) {
+ throw new AssertionFailedError(
+ 'Child coroutines were still running at the time limit after the test threw an expected exception.',
+ 0,
+ $this->exceptionOutlivedByChildCoroutines,
+ );
+ }
+ }
+
/**
* Determine whether tests run in a coroutine.
*/
diff --git a/tests/Testing/PHPUnit/Fixtures/TimeLimitFixture.php b/tests/Testing/PHPUnit/Fixtures/TimeLimitFixture.php
index a5963dfa08..37cb838b2c 100644
--- a/tests/Testing/PHPUnit/Fixtures/TimeLimitFixture.php
+++ b/tests/Testing/PHPUnit/Fixtures/TimeLimitFixture.php
@@ -6,11 +6,23 @@
use Hypervel\Engine\Coroutine as EngineCoroutine;
use Hypervel\Tests\TestCase;
+use PHPUnit\Framework\AssertionFailedError;
+use RuntimeException;
use Swoole\Coroutine;
use Swoole\Coroutine\Channel;
class TimeLimitFixture extends TestCase
{
+ /**
+ * Record that the test was torn down.
+ */
+ protected function tearDown(): void
+ {
+ $this->record('tearDown ' . $this->name());
+
+ parent::tearDown();
+ }
+
public function testNonYieldingWorkIsAborted(): void
{
while (true);
@@ -24,9 +36,67 @@ public function testSleepingWorkIsAborted(): void
public function testPendingChildrenAreAborted(): void
{
- Coroutine::create(function (): void {
- $this->assertFalse((new Channel)->pop());
- });
+ $this->startPendingChild();
+ }
+
+ public function testSkippedTestWithPendingChildrenIsAborted(): void
+ {
+ $this->startPendingChild();
+
+ $this->markTestSkipped('The test skipped itself.');
+ }
+
+ public function testIncompleteTestWithPendingChildrenIsAborted(): void
+ {
+ $this->startPendingChild();
+
+ $this->markTestIncomplete('The test marked itself incomplete.');
+ }
+
+ public function testAssertionFailureIsReportedWhenChildrenReachTheTimeLimit(): void
+ {
+ $this->startPendingChild();
+
+ $this->fail('The original assertion failure.');
+ }
+
+ public function testErrorIsReportedWhenChildrenReachTheTimeLimit(): void
+ {
+ $this->startPendingChild();
+
+ throw new RuntimeException('The original error.');
+ }
+
+ public function testExpectedExceptionFailsWhenChildrenReachTheTimeLimit(): void
+ {
+ $this->expectException(RuntimeException::class);
+ $this->startPendingChild();
+
+ throw new RuntimeException('The expected exception.');
+ }
+
+ public function testExpectedAssertionFailureFailsWhenChildrenReachTheTimeLimit(): void
+ {
+ $this->expectException(AssertionFailedError::class);
+ $this->startPendingChild();
+
+ $this->fail('The expected assertion failure.');
+ }
+
+ public function testExpectedExceptionWaitsForItsChildren(): void
+ {
+ $this->expectException(RuntimeException::class);
+ $this->startCompletingChild();
+
+ throw new RuntimeException('The expected exception.');
+ }
+
+ public function testExpectedAssertionFailureWaitsForItsChildren(): void
+ {
+ $this->expectException(AssertionFailedError::class);
+ $this->startCompletingChild();
+
+ $this->fail('The expected assertion failure.');
}
public function testChildrenCompleteNormally(): void
@@ -49,4 +119,32 @@ public function testWorkWithoutTimeLimitCompletes(): void
{
$this->assertTrue(Coroutine::sleep(0.01));
}
+
+ /**
+ * Start a child that waits until the time limit cancels it.
+ */
+ protected function startPendingChild(): void
+ {
+ Coroutine::create(function (): void {
+ $this->assertFalse((new Channel)->pop());
+ });
+ }
+
+ /**
+ * Start a child that finishes after the test method has returned or thrown.
+ */
+ protected function startCompletingChild(): void
+ {
+ Coroutine::create(function (): void {
+ $this->record(Coroutine::sleep(0.05) ? 'child completed' : 'child cancelled');
+ });
+ }
+
+ /**
+ * Record an event in the log the fixture's runner reads.
+ */
+ protected function record(string $event): void
+ {
+ file_put_contents((string) getenv('TIME_LIMIT_FIXTURE_LOG'), $event . PHP_EOL, FILE_APPEND);
+ }
}
diff --git a/tests/Testing/PHPUnit/TimeLimitTest.php b/tests/Testing/PHPUnit/TimeLimitTest.php
index 5d4bd3c9b1..581fff01aa 100644
--- a/tests/Testing/PHPUnit/TimeLimitTest.php
+++ b/tests/Testing/PHPUnit/TimeLimitTest.php
@@ -4,6 +4,8 @@
namespace Hypervel\Tests\Testing\PHPUnit;
+use Hypervel\Filesystem\Filesystem;
+use Hypervel\Testing\ParallelTesting;
use Hypervel\Tests\TestCase;
use PHPUnit\Framework\Attributes\DataProvider;
use Symfony\Component\Process\Process;
@@ -12,6 +14,30 @@ class TimeLimitTest extends TestCase
{
protected bool $runTestsInCoroutine = false;
+ protected string $tempDirectory;
+
+ /**
+ * Set up the test environment.
+ */
+ protected function setUp(): void
+ {
+ parent::setUp();
+
+ $this->tempDirectory = ParallelTesting::tempDir('TimeLimitTest');
+ (new Filesystem)->deleteDirectory($this->tempDirectory);
+ mkdir($this->tempDirectory, 0777, true);
+ }
+
+ /**
+ * Clean up the test environment.
+ */
+ protected function tearDown(): void
+ {
+ (new Filesystem)->deleteDirectory($this->tempDirectory);
+
+ parent::tearDown();
+ }
+
#[DataProvider('timedOutTests')]
public function testTimedOutTestsFailWithTheirIdentity(string $method): void
{
@@ -33,15 +59,59 @@ public static function timedOutTests(): array
'non-yielding' => ['testNonYieldingWorkIsAborted'],
'sleeping root' => ['testSleepingWorkIsAborted'],
'pending child after root exits' => ['testPendingChildrenAreAborted'],
+ 'pending child after a skip' => ['testSkippedTestWithPendingChildrenIsAborted'],
+ 'pending child after marking incomplete' => ['testIncompleteTestWithPendingChildrenIsAborted'],
+ ];
+ }
+
+ #[DataProvider('failedTests')]
+ public function testFailuresKeepTheirCauseWhenChildrenReachTheTimeLimit(string $method, array $messages): void
+ {
+ $process = $this->runFixture($method, 1);
+ $output = $process->getOutput() . $process->getErrorOutput();
+
+ $this->assertFalse($process->isSuccessful());
+ $this->assertStringContainsString('TimeLimitFixture::' . $method, $output);
+
+ foreach ($messages as $message) {
+ $this->assertStringContainsString($message, $output);
+ }
+
+ $this->assertStringNotContainsString('This test was aborted', $output);
+ $this->assertContains('tearDown ' . $method, $this->fixtureEvents());
+ }
+
+ /**
+ * Provide tests that throw before their pending children reach the deadline.
+ */
+ public static function failedTests(): array
+ {
+ $childrenStillRunning = 'Child coroutines were still running at the time limit after the test threw an expected exception.';
+
+ return [
+ 'assertion failure' => ['testAssertionFailureIsReportedWhenChildrenReachTheTimeLimit', ['The original assertion failure.']],
+ 'error' => ['testErrorIsReportedWhenChildrenReachTheTimeLimit', ['RuntimeException: The original error.']],
+ 'expected exception' => [
+ 'testExpectedExceptionFailsWhenChildrenReachTheTimeLimit',
+ [$childrenStillRunning, 'RuntimeException: The expected exception.'],
+ ],
+ 'expected assertion failure' => [
+ 'testExpectedAssertionFailureFailsWhenChildrenReachTheTimeLimit',
+ [$childrenStillRunning, 'The expected assertion failure.'],
+ ],
];
}
#[DataProvider('completedTests')]
- public function testCompletedWorkDoesNotTimeOut(string $method, int $limit): void
+ public function testCompletedWorkDoesNotTimeOut(string $method, int $limit, array $events): void
{
$process = $this->runFixture($method, $limit);
$this->assertTrue($process->isSuccessful(), $process->getOutput() . $process->getErrorOutput());
+
+ foreach ($events as $event) {
+ $this->assertContains($event, $this->fixtureEvents());
+ }
}
/**
@@ -50,8 +120,14 @@ public function testCompletedWorkDoesNotTimeOut(string $method, int $limit): voi
public static function completedTests(): array
{
return [
- 'child completes after root exits' => ['testChildrenCompleteNormally', 1],
- 'no time limit' => ['testWorkWithoutTimeLimitCompletes', 0],
+ 'child completes after root exits' => ['testChildrenCompleteNormally', 1, []],
+ 'no time limit' => ['testWorkWithoutTimeLimitCompletes', 0, []],
+ 'child completes after an expected exception' => ['testExpectedExceptionWaitsForItsChildren', 1, ['child completed']],
+ 'child completes after an expected assertion failure' => [
+ 'testExpectedAssertionFailureWaitsForItsChildren',
+ 1,
+ ['child completed'],
+ ],
];
}
@@ -71,10 +147,21 @@ protected function runFixture(string $method, int $limit): Process
'tests/Testing/PHPUnit/Fixtures/TimeLimitFixture.php',
],
cwd: dirname(__DIR__, 3),
+ env: ['TIME_LIMIT_FIXTURE_LOG' => $this->tempDirectory . '/events.log'],
timeout: 15,
);
$process->run();
return $process;
}
+
+ /**
+ * Get the events the fixture recorded.
+ *
+ * @return list
+ */
+ protected function fixtureEvents(): array
+ {
+ return file($this->tempDirectory . '/events.log', FILE_IGNORE_NEW_LINES);
+ }
}
From 750d3f0123c99ab6bac9135013d08f15e463d9a2 Mon Sep 17 00:00:00 2001
From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com>
Date: Sat, 10 Oct 2026 11:50:13 +0000
Subject: [PATCH 11/18] Compute Fortify and Horizon configuration again in each
worker
Fortify derived its passkey settings from the application URL and key, and Horizon filled a missing name from the application name. Both wrote the results into configuration while the server booted, and the configuration tracker replayed those values literally into every worker. A worker whose environment changed the URL, key or application name kept the server's values. Horizon also selected its Redis connection once from the boot-time configuration.
Run each through configureUsing() so every worker derives it again from its rebuilt configuration. Values the application sets explicitly still take precedence.
Add regression tests that replay the boot-time mutations into rebuilt configurations. Each fails before the fix. Formatting, static analysis and the Fortify and Horizon suites pass.
---
src/fortify/src/FortifyServiceProvider.php | 26 +++---
src/horizon/src/HorizonServiceProvider.php | 15 ++--
tests/Fortify/FortifyServiceProviderTest.php | 33 ++++++++
tests/Horizon/HorizonConfigTest.php | 84 ++++++++++++++++++++
4 files changed, 139 insertions(+), 19 deletions(-)
diff --git a/src/fortify/src/FortifyServiceProvider.php b/src/fortify/src/FortifyServiceProvider.php
index b793377c0b..e675e7aa5b 100644
--- a/src/fortify/src/FortifyServiceProvider.php
+++ b/src/fortify/src/FortifyServiceProvider.php
@@ -118,19 +118,19 @@ protected function configurePasskeys(): void
{
Passkeys::ignoreRoutes();
- $config = $this->app->make(Config::class);
-
- /** @var null|string $appUrl */
- $appUrl = $config->get('app.url');
- $defaultRelyingPartyId = $appUrl === null ? null : parse_url($appUrl, PHP_URL_HOST);
- $defaultAllowedOrigins = $appUrl === null ? [] : [$appUrl];
-
- $config->set([
- 'passkeys.relying_party_id' => $config->get('fortify.passkeys.relying_party_id', $defaultRelyingPartyId),
- 'passkeys.allowed_origins' => $config->get('fortify.passkeys.allowed_origins', $defaultAllowedOrigins),
- 'passkeys.user_handle_secret' => $config->get('fortify.passkeys.user_handle_secret', $config->get('app.key')),
- 'passkeys.timeout' => $config->integer('fortify.passkeys.timeout', Passkeys::DEFAULT_TIMEOUT),
- ]);
+ $this->configureUsing(static function (Config $config): void {
+ /** @var null|string $appUrl */
+ $appUrl = $config->get('app.url');
+ $defaultRelyingPartyId = $appUrl === null ? null : parse_url($appUrl, PHP_URL_HOST);
+ $defaultAllowedOrigins = $appUrl === null ? [] : [$appUrl];
+
+ $config->set([
+ 'passkeys.relying_party_id' => $config->get('fortify.passkeys.relying_party_id', $defaultRelyingPartyId),
+ 'passkeys.allowed_origins' => $config->get('fortify.passkeys.allowed_origins', $defaultAllowedOrigins),
+ 'passkeys.user_handle_secret' => $config->get('fortify.passkeys.user_handle_secret', $config->get('app.key')),
+ 'passkeys.timeout' => $config->integer('fortify.passkeys.timeout', Passkeys::DEFAULT_TIMEOUT),
+ ]);
+ });
Passkeys::redirectUsing(
static fn (Request $request): string => Fortify::redirects('login', request: $request),
diff --git a/src/horizon/src/HorizonServiceProvider.php b/src/horizon/src/HorizonServiceProvider.php
index 019d35aaa6..96811305e1 100644
--- a/src/horizon/src/HorizonServiceProvider.php
+++ b/src/horizon/src/HorizonServiceProvider.php
@@ -4,6 +4,7 @@
namespace Hypervel\Horizon;
+use Hypervel\Contracts\Config\Repository;
use Hypervel\Contracts\Events\Dispatcher;
use Hypervel\Contracts\Redis\Factory as RedisFactory;
use Hypervel\Horizon\Connectors\RedisConnector;
@@ -40,11 +41,11 @@ public function boot(): void
*/
protected function normalizeConfig(): void
{
- $config = $this->app->make('config');
-
- if (($name = $config->get('horizon.name')) === null || $name === '') {
- $config->set('horizon.name', $config->string('app.name'));
- }
+ $this->configureUsing(static function (Repository $config): void {
+ if (($name = $config->get('horizon.name')) === null || $name === '') {
+ $config->set('horizon.name', $config->string('app.name'));
+ }
+ });
}
/**
@@ -165,7 +166,9 @@ protected function configure(): void
'horizon'
);
- Horizon::use(config()->string('horizon.use'));
+ $this->configureUsing(static function (Repository $config): void {
+ Horizon::use($config->string('horizon.use'));
+ });
}
/**
diff --git a/tests/Fortify/FortifyServiceProviderTest.php b/tests/Fortify/FortifyServiceProviderTest.php
index 651eaaf970..0738c81032 100644
--- a/tests/Fortify/FortifyServiceProviderTest.php
+++ b/tests/Fortify/FortifyServiceProviderTest.php
@@ -5,6 +5,7 @@
namespace Hypervel\Tests\Fortify;
use Closure;
+use Hypervel\Config\Repository as ConfigRepository;
use Hypervel\Contracts\Support\Responsable;
use Hypervel\Fortify\Contracts\CreatesNewUsers;
use Hypervel\Fortify\Contracts\RedirectsIfTwoFactorAuthenticatable;
@@ -12,6 +13,8 @@
use Hypervel\Fortify\Fortify;
use Hypervel\Fortify\FortifyServiceProvider;
use Hypervel\Fortify\RecoveryCode;
+use Hypervel\Foundation\Application;
+use Hypervel\Foundation\Configuration\ConfigMutationTracker;
use Hypervel\Http\JsonResponse;
use Hypervel\Http\Request;
use Hypervel\Passkeys\Passkeys;
@@ -150,6 +153,36 @@ public function testOmittedPasskeySettingsUseTheApplicationAndPackageDefaults():
$this->assertSame(Passkeys::DEFAULT_TIMEOUT, config('passkeys.timeout'));
}
+ public function testPasskeySettingsAreComputedAgainAgainstTheRebuiltConfiguration(): void
+ {
+ $masterConfig = new ConfigRepository([
+ 'app' => ['url' => 'https://old.example.test', 'key' => 'old-key'],
+ 'fortify' => ['passkeys' => ['timeout' => 30000]],
+ ]);
+ $tracker = new ConfigMutationTracker;
+ $tracker->observe($masterConfig);
+ $app = m::mock(Application::class)->makePartial();
+ $app->shouldReceive('configurationIsCached')->andReturnFalse();
+ $app->shouldReceive('make')->with('config')->andReturn($masterConfig);
+ $app->shouldReceive('make')->with(ConfigMutationTracker::class)->andReturn($tracker);
+
+ (new ReflectionMethod(FortifyServiceProvider::class, 'configurePasskeys'))->invoke(new FortifyServiceProvider($app));
+
+ $this->assertSame('old.example.test', $masterConfig->get('passkeys.relying_party_id'));
+
+ // A worker whose environment changed the application URL and key derives the defaults from them, keeping explicit settings.
+ $workerConfig = new ConfigRepository([
+ 'app' => ['url' => 'https://new.example.test', 'key' => 'new-key'],
+ 'fortify' => ['passkeys' => ['relying_party_id' => 'passkeys.example.test', 'timeout' => 30000]],
+ ]);
+ $tracker->replay($workerConfig);
+
+ $this->assertSame('passkeys.example.test', $workerConfig->get('passkeys.relying_party_id'));
+ $this->assertSame(['https://new.example.test'], $workerConfig->get('passkeys.allowed_origins'));
+ $this->assertSame('new-key', $workerConfig->get('passkeys.user_handle_secret'));
+ $this->assertSame(30000, $workerConfig->get('passkeys.timeout'));
+ }
+
#[DefineEnvironment('withTwoFactorAuthentication')]
public function testRedirectIfTwoFactorAuthenticatableIsResolvedFreshAfterFlushingScopedInstances(): void
{
diff --git a/tests/Horizon/HorizonConfigTest.php b/tests/Horizon/HorizonConfigTest.php
index ae0ecb71af..52b743f238 100644
--- a/tests/Horizon/HorizonConfigTest.php
+++ b/tests/Horizon/HorizonConfigTest.php
@@ -5,6 +5,7 @@
namespace Hypervel\Tests\Horizon;
use Hypervel\Config\Repository as ConfigRepository;
+use Hypervel\Container\Container;
use Hypervel\Foundation\Application;
use Hypervel\Foundation\Configuration\ConfigMutationTracker;
use Hypervel\Horizon\HorizonServiceProvider;
@@ -73,7 +74,9 @@ public function testOnlyMissingAndBlankNamesUseTheApplicationName(): void
'horizon' => ['name' => $name],
]);
$app = m::mock(Application::class)->makePartial();
+ $app->shouldReceive('configurationIsCached')->andReturnFalse();
$app->shouldReceive('make')->with('config')->andReturn($config);
+ $app->shouldReceive('make')->with(ConfigMutationTracker::class)->andReturn(new ConfigMutationTracker);
(new HorizonServiceProviderForTesting($app))->normalize();
@@ -81,6 +84,76 @@ public function testOnlyMissingAndBlankNamesUseTheApplicationName(): void
}
}
+ public function testApplicationNameFallbackIsComputedAgainAgainstTheRebuiltConfiguration(): void
+ {
+ $masterConfig = new ConfigRepository([
+ 'app' => ['name' => 'Old Name'],
+ 'horizon' => ['name' => null],
+ ]);
+ $tracker = new ConfigMutationTracker;
+ $tracker->observe($masterConfig);
+ $app = m::mock(Application::class)->makePartial();
+ $app->shouldReceive('configurationIsCached')->andReturnFalse();
+ $app->shouldReceive('make')->with('config')->andReturn($masterConfig);
+ $app->shouldReceive('make')->with(ConfigMutationTracker::class)->andReturn($tracker);
+
+ (new HorizonServiceProviderForTesting($app))->normalize();
+
+ $this->assertSame('Old Name', $masterConfig->get('horizon.name'));
+
+ // A worker whose environment renamed the application uses the new name, unless Horizon's own name is now set.
+ $workerConfig = new ConfigRepository([
+ 'app' => ['name' => 'New Name'],
+ 'horizon' => ['name' => null],
+ ]);
+ $tracker->replay($workerConfig);
+
+ $this->assertSame('New Name', $workerConfig->get('horizon.name'));
+
+ $namedWorkerConfig = new ConfigRepository([
+ 'app' => ['name' => 'New Name'],
+ 'horizon' => ['name' => 'Queues'],
+ ]);
+ $tracker->replay($namedWorkerConfig);
+
+ $this->assertSame('Queues', $namedWorkerConfig->get('horizon.name'));
+ }
+
+ public function testRedisConnectionIsComputedAgainAgainstTheRebuiltConfiguration(): void
+ {
+ $masterConfig = new ConfigRepository([
+ 'database' => ['redis' => ['default' => ['host' => 'old-host']]],
+ 'horizon' => ['prefix' => 'old_horizon:'],
+ ]);
+ $tracker = new ConfigMutationTracker;
+ $tracker->observe($masterConfig);
+ // Horizon::use() reads the application's configuration, which a rebuild refreshes in place.
+ $currentConfig = $masterConfig;
+ $app = m::mock(Application::class)->makePartial();
+ $app->shouldReceive('configurationIsCached')->andReturnFalse();
+ $app->shouldReceive('make')->with('config')->andReturnUsing(static function () use (&$currentConfig): ConfigRepository {
+ return $currentConfig;
+ });
+ $app->shouldReceive('make')->with(ConfigMutationTracker::class)->andReturn($tracker);
+ Container::setInstance($app);
+
+ (new HorizonServiceProviderForTesting($app))->configureForTest();
+
+ $this->assertSame('old-host', $masterConfig->get('database.redis.horizon.host'));
+ $this->assertSame('old_horizon:', $masterConfig->get('database.redis.horizon.prefix'));
+
+ // A worker whose environment moved Redis and changed the prefix connects Horizon with the new settings.
+ $currentConfig = $workerConfig = new ConfigRepository([
+ 'database' => ['redis' => ['default' => ['host' => 'new-host']]],
+ 'horizon' => ['prefix' => 'new_horizon:'],
+ ]);
+ $tracker->replay($workerConfig);
+
+ $this->assertSame('new-host', $workerConfig->get('database.redis.horizon.host'));
+ $this->assertSame('new_horizon:', $workerConfig->get('database.redis.horizon.prefix'));
+ $this->assertSame('new_horizon:', $workerConfig->get('horizon.prefix'));
+ }
+
public function testMissingAndBlankPrefixUseApplicationScopedDefault(): void
{
foreach ([null, ''] as $prefix) {
@@ -108,8 +181,19 @@ public function register(): void
class HorizonServiceProviderForTesting extends HorizonServiceProvider
{
+ /**
+ * Normalize the Horizon configuration.
+ */
public function normalize(): void
{
$this->normalizeConfig();
}
+
+ /**
+ * Configure Horizon for the test.
+ */
+ public function configureForTest(): void
+ {
+ $this->configure();
+ }
}
From ea02aa4f9bcfa878538ae050e41545df7f411ae1 Mon Sep 17 00:00:00 2001
From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com>
Date: Sat, 10 Oct 2026 11:51:08 +0000
Subject: [PATCH 12/18] Let Telescope record cache events without rewriting
store configuration
Cache repositories dispatch events by default. A store opts out with "events" set to false, and the failover repository turns off its outer events because its backing stores dispatch them. When the cache watcher was enabled, Telescope instead set "events" to true on every configured store while the application registered. That re-enabled stores the application had deliberately quieted and recorded every failover operation twice. The change was also boot-time configuration that workers did not derive again, and it relied on a static flag.
Register the watcher's listeners directly and leave store configuration alone. Remove the static flag and its test-state cleanup.
Add regression tests for a failover store recording each operation once and for a store with events disabled recording nothing. Both fail before the fix. The disabled-watcher test now asserts that no cache entries are recorded instead of inspecting configuration. Formatting, static analysis and the Telescope suite pass.
---
.../src/TelescopeServiceProvider.php | 14 -------
src/telescope/src/Watchers/CacheWatcher.php | 34 -----------------
.../src/PHPUnit/AfterEachTestSubscriber.php | 1 -
tests/Telescope/Watchers/CacheWatcherTest.php | 38 +++++++++----------
.../Watchers/DisabledWatcherTest.php | 21 ++++------
5 files changed, 27 insertions(+), 81 deletions(-)
diff --git a/src/telescope/src/TelescopeServiceProvider.php b/src/telescope/src/TelescopeServiceProvider.php
index 5b46c4d0c2..6798fcc403 100644
--- a/src/telescope/src/TelescopeServiceProvider.php
+++ b/src/telescope/src/TelescopeServiceProvider.php
@@ -17,7 +17,6 @@
use Hypervel\Telescope\Contracts\EntriesRepository;
use Hypervel\Telescope\Contracts\PrunableRepository;
use Hypervel\Telescope\Storage\DatabaseEntriesRepository;
-use Hypervel\Telescope\Watchers\CacheWatcher;
use Hypervel\Telescope\Watchers\ClientRequestWatcher;
use Hypervel\Telescope\Watchers\RedisWatcher;
@@ -139,7 +138,6 @@ public function register(): void
}
$this->registerRedisEvents();
- $this->registerCacheEvents();
$this->registerGuzzleHttpClientAspect();
}
@@ -155,18 +153,6 @@ protected function registerRedisEvents(): void
RedisWatcher::enableRedisEvents($this->app);
}
- /**
- * Register the Cache events if the watcher is enabled.
- */
- protected function registerCacheEvents(): void
- {
- if (! $this->watcherIsEnabled(CacheWatcher::class)) {
- return;
- }
-
- CacheWatcher::enableCacheEvents($this->app);
- }
-
/**
* Register the Guzzle HTTP client aspect if the watcher is enabled.
*/
diff --git a/src/telescope/src/Watchers/CacheWatcher.php b/src/telescope/src/Watchers/CacheWatcher.php
index b5d731a1ee..8061218f6a 100644
--- a/src/telescope/src/Watchers/CacheWatcher.php
+++ b/src/telescope/src/Watchers/CacheWatcher.php
@@ -17,20 +17,11 @@
class CacheWatcher extends Watcher
{
- /**
- * Indicates if the cache event is enabled.
- */
- protected static bool $eventsEnabled = false;
-
/**
* Register the watcher.
*/
public function register(Application $app): void
{
- if (! static::$eventsEnabled) {
- return;
- }
-
$event = $app->make(Dispatcher::class);
$event->listen(CacheHit::class, [$this, 'recordCacheHit']);
@@ -39,23 +30,6 @@ public function register(Application $app): void
$event->listen(KeyForgotten::class, [$this, 'recordKeyForgotten']);
}
- /**
- * Enable Cache events.
- *
- * Boot-only. Must be called before the Cache is initialized. Mutates
- * process-global config and a static flag; runtime use races across
- * coroutines.
- */
- public static function enableCacheEvents(Application $app): void
- {
- $config = $app->make('config');
- foreach (array_keys($config->array('cache.stores')) as $store) {
- $config->set("cache.stores.{$store}.events", true);
- }
-
- static::$eventsEnabled = true;
- }
-
/**
* Record a cache key was found.
*/
@@ -156,12 +130,4 @@ private function shouldIgnore(mixed $event): bool
'telescope:*',
]), $event->key);
}
-
- /**
- * Flush all static state.
- */
- public static function flushState(): void
- {
- static::$eventsEnabled = false;
- }
}
diff --git a/src/testing/src/PHPUnit/AfterEachTestSubscriber.php b/src/testing/src/PHPUnit/AfterEachTestSubscriber.php
index 3df6da90f0..033b6f0823 100644
--- a/src/testing/src/PHPUnit/AfterEachTestSubscriber.php
+++ b/src/testing/src/PHPUnit/AfterEachTestSubscriber.php
@@ -492,7 +492,6 @@ protected function flushSentryState(): void
protected function flushTelescopeState(): void
{
$this->callIfExists(\Hypervel\Telescope\Telescope::class, 'flushState');
- $this->callIfExists(\Hypervel\Telescope\Watchers\CacheWatcher::class, 'flushState');
$this->callIfExists(\Hypervel\Telescope\Watchers\DumpWatcher::class, 'flushState');
$this->callIfExists(\Hypervel\Telescope\Watchers\RedisWatcher::class, 'flushState');
}
diff --git a/tests/Telescope/Watchers/CacheWatcherTest.php b/tests/Telescope/Watchers/CacheWatcherTest.php
index e0c9877ad9..4738893c22 100644
--- a/tests/Telescope/Watchers/CacheWatcherTest.php
+++ b/tests/Telescope/Watchers/CacheWatcherTest.php
@@ -4,13 +4,13 @@
namespace Hypervel\Tests\Telescope\Watchers;
+use Hypervel\Contracts\Cache\Factory;
use Hypervel\Contracts\Cache\Repository;
use Hypervel\Telescope\EntryType;
use Hypervel\Telescope\Telescope;
use Hypervel\Telescope\Watchers\CacheWatcher;
use Hypervel\Testbench\Attributes\WithConfig;
use Hypervel\Tests\Telescope\FeatureTestCase;
-use ReflectionClass;
#[WithConfig('telescope.watchers', [
CacheWatcher::class => [
@@ -26,22 +26,6 @@
])]
class CacheWatcherTest extends FeatureTestCase
{
- protected function setUp(): void
- {
- parent::setUp();
-
- CacheWatcher::enableCacheEvents($this->app);
- }
-
- public function testFlushStateDisablesCacheEvents(): void
- {
- $this->assertTrue($this->eventsAreEnabled());
-
- CacheWatcher::flushState();
-
- $this->assertFalse($this->eventsAreEnabled());
- }
-
public function testCacheWatcherRegistersMissedEntries(): void
{
$this->app->make(Repository::class)->get('empty-key');
@@ -147,8 +131,24 @@ public function testCacheWatcherSkipsRecordingIgnoredCacheKeys(): void
$this->assertSame('laravel', $entry->content['value']);
}
- private function eventsAreEnabled(): bool
+ #[WithConfig('cache.stores.failover', ['driver' => 'failover', 'stores' => ['array']])]
+ public function testCacheWatcherRecordsFailoverStoreOperationsOnce(): void
{
- return (new ReflectionClass(CacheWatcher::class))->getStaticPropertyValue('eventsEnabled');
+ // The failover repository leaves its events to the store it uses.
+ $this->app->make(Factory::class)->store('failover')->get('failover-key');
+
+ $entries = $this->loadTelescopeEntries();
+
+ $this->assertCount(1, $entries);
+ $this->assertSame('missed', $entries->first()->content['type']);
+ $this->assertSame('failover-key', $entries->first()->content['key']);
+ }
+
+ #[WithConfig('cache.stores.quiet', ['driver' => 'array', 'events' => false])]
+ public function testCacheWatcherLeavesStoresWithoutEventsUnrecorded(): void
+ {
+ $this->app->make(Factory::class)->store('quiet')->put('quiet-key', 'laravel', 1);
+
+ $this->assertCount(0, $this->loadTelescopeEntries());
}
}
diff --git a/tests/Telescope/Watchers/DisabledWatcherTest.php b/tests/Telescope/Watchers/DisabledWatcherTest.php
index 597632c358..7171fbe60e 100644
--- a/tests/Telescope/Watchers/DisabledWatcherTest.php
+++ b/tests/Telescope/Watchers/DisabledWatcherTest.php
@@ -4,7 +4,7 @@
namespace Hypervel\Tests\Telescope\Watchers;
-use Hypervel\Contracts\Foundation\Application as ApplicationContract;
+use Hypervel\Contracts\Cache\Repository;
use Hypervel\Di\Aop\AspectCollector;
use Hypervel\Redis\RedisConfig;
use Hypervel\Telescope\Aspects\GuzzleHttpClientAspect;
@@ -18,24 +18,20 @@
class DisabledWatcherTest extends FeatureTestCase
{
- protected function defineEnvironment(ApplicationContract $app): void
- {
- parent::defineEnvironment($app);
-
- // Override the FeatureTestCase default so we can detect whether
- // the watcher's enableCacheEvents() mutated this config.
- $app->make('config')->set('cache.stores.array.events', false);
- }
-
#[WithConfig('telescope.watchers', [
CacheWatcher::class => [
'enabled' => false,
'hidden' => [],
],
])]
- public function testDisabledCacheWatcherDoesNotEnableCacheEvents(): void
+ public function testDisabledCacheWatcherRecordsNoCacheEntries(): void
{
- $this->assertFalse(config()->boolean('cache.stores.array.events'));
+ $repository = $this->app->make(Repository::class);
+
+ $repository->put('disabled-key', 'laravel', 1);
+ $repository->get('disabled-key');
+
+ $this->assertCount(0, $this->loadTelescopeEntries());
}
#[WithConfig('telescope.watchers', [
@@ -76,7 +72,6 @@ public function testGloballyDisabledTelescopeRegistersStorageWithoutInstrumentat
DatabaseEntriesRepository::class,
$this->app->make(EntriesRepository::class),
);
- $this->assertFalse(config('cache.stores.array.events'));
$this->assertFalse(
$this->app->make(RedisConfig::class)
->connectionConfig('foo')['events'],
From fdba812052ac17b737e898195337138ff3c3f2b7 Mon Sep 17 00:00:00 2001
From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com>
Date: Sat, 10 Oct 2026 12:07:39 +0000
Subject: [PATCH 13/18] Pool FTP and SFTP disks so concurrent requests never
share a connection
Each FTP and SFTP adapter holds one open connection. Under Swoole's
coroutine hooks, two coroutines using the same disk at once read or write
the same socket, and Swoole kills the worker with "Socket#N has already
been bound to another coroutine". Separate adapters do not interfere.
Add both drivers to the whole-driver pooled drivers, so each operation
borrows a disk with its own connection. The existing pool proxy already
keeps streams leased until closed, loads listings inside the borrow and
blocks raw access outside it. Purging the pools destroys idle disks, and
the adapters' destructors close their connections.
The filesystem docs explain the pooling and the per-worker connection
count, the README records the difference from Laravel, and the porting
guide now covers raw access on every pooled disk, including S3 and GCS.
Validated against local FTP and SFTP servers: eight concurrent reads
through a shared disk crash the worker before this change and all
succeed after it, and purging closes every connection. The filesystem
suite, lint and static analysis pass.
---
src/docs/filesystem.md | 6 ++++--
src/docs/porting-from-laravel.md | 2 ++
src/filesystem/README.md | 2 +-
src/filesystem/src/FilesystemManager.php | 2 +-
tests/Filesystem/FilesystemManagerTest.php | 18 ++++++++++++++++++
5 files changed, 26 insertions(+), 4 deletions(-)
diff --git a/src/docs/filesystem.md b/src/docs/filesystem.md
index 40d4234808..2cc8906dc0 100644
--- a/src/docs/filesystem.md
+++ b/src/docs/filesystem.md
@@ -232,6 +232,8 @@ The `s3` and `gcs` drivers pool their SDK clients by default. The bucket-specifi
Pool identity is derived from the exact normalized configuration passed to the SDK client constructor. Equivalent client configurations converge automatically, including repeated `Storage::build()` calls. These configurations must use the same pool options; a mismatch throws immediately instead of silently reusing the first configuration's settings. Different credentials, regions, endpoints, or client options produce different pools.
+The `ftp` and `sftp` drivers keep an open connection inside each disk, so Hypervel pools these disks whole. Each operation borrows a disk with its own connection, so concurrent requests never share one.
+
You may configure a pool using the disk's `pool` option:
```php
@@ -249,7 +251,7 @@ You may configure a pool using the disk's `pool` option:
],
```
-`min_retained_objects` is an idle-trimming floor; it does not eagerly create clients. `max_lifetime` expires clients by absolute age, while `max_idle_time` trims individual idle clients. `pool_idle_timeout` removes an entirely unused pool after 300 seconds by default. Set any of these three optional durations to `null` to disable it. If all clients are in use and no capacity becomes available before `wait_timeout`, a `RuntimeException` is thrown.
+`min_retained_objects` is an idle-trimming floor; it does not eagerly create clients. `max_lifetime` expires clients by absolute age, while `max_idle_time` trims individual idle clients. `pool_idle_timeout` removes an entirely unused pool after 300 seconds by default. Set any of these three optional durations to `null` to disable it. If all clients are in use and no capacity becomes available before `wait_timeout`, a `RuntimeException` is thrown. Each worker has its own pools, so an FTP or SFTP disk may open up to `max_objects` connections in every worker; keep that total within your server's connection limit.
An explicit pool name may be useful when multiple configurations intentionally identify the same operational resource:
@@ -285,7 +287,7 @@ $result = Storage::disk('s3')->withClient(function ($client) {
});
```
-`Storage::forgetDisk()` only removes the manager's cached disk wrapper; an equivalent wrapper can continue using the shared pool. `Storage::purge()` removes the wrapper and closes its current pool, deriving the same pool identity even when the named disk has not been resolved yet or is composed from nested scoped disks. Other disks converging on that pool transparently create a fresh one on their next operation. Streams returned by `readStream()` or `readStreamRange()` retain their client lease until the stream is closed or destroyed.
+`Storage::forgetDisk()` only removes the manager's cached disk wrapper; an equivalent wrapper can continue using the shared pool. `Storage::purge()` removes the wrapper and closes its current pool, deriving the same pool identity even when the named disk has not been resolved yet or is composed from nested scoped disks. Other disks converging on that pool transparently create a fresh one on their next operation. Streams returned by `readStream()` or `readStreamRange()` retain their lease until the stream is closed or destroyed.
S3 and Google Cloud Storage streams are read lazily by default, which keeps memory usage bounded and makes data available before the entire file has downloaded. This applies to `readStream()` and `readStreamRange()`; methods such as `get()` retain their normal behavior. Streaming requests close their HTTP connection after the read, so applications that open many small streams may prefer connection reuse and set the disk's `stream_reads` option to `false`.
diff --git a/src/docs/porting-from-laravel.md b/src/docs/porting-from-laravel.md
index a6822fffad..571ad678cd 100644
--- a/src/docs/porting-from-laravel.md
+++ b/src/docs/porting-from-laravel.md
@@ -658,6 +658,8 @@ Hypervel's `Filesystem::hash()` method uses `xxh128` by default. Pass `md5` expl
Custom filesystem contract implementations must also provide `fileExists()` and `directoryExists()`. The existing `exists()` method continues to accept either a file or a directory. See [retrieving files](/docs/{{version}}/filesystem#retrieving-files).
+S3, Google Cloud Storage, FTP, SFTP, and poolable custom disks are pooled, so they do not hand out objects that outlive an operation. Replace `getAdapter()` and `getDriver()` with `withAdapter()` and `withDriver()`, and an S3 or Google Cloud Storage disk's `getClient()` with `withClient()`; each callback receives the borrowed object. Methods a pooled disk does not support throw instead of forwarding to the driver. See [driver pools](/docs/{{version}}/filesystem#driver-pools).
+
Unlike Laravel, Hypervel honors `read-only` on scoped disk records. Remove that option from any scoped disk that must accept writes.
Rename any configured disk called `ondemand`; Hypervel reserves that name for [on-demand disk fakes](/docs/{{version}}/filesystem#on-demand-disks).
diff --git a/src/filesystem/README.md b/src/filesystem/README.md
index 7b32cd144d..47746c4f1d 100644
--- a/src/filesystem/README.md
+++ b/src/filesystem/README.md
@@ -15,7 +15,7 @@ Custom implementations of `Hypervel\Contracts\Filesystem\Filesystem` must provid
The configured disk name `ondemand` is reserved. Disks explicitly set under that name, including fakes, intercept anonymous `build()` calls; configured disks with that name are rejected on resolution.
-Hypervel pools S3 and Google Cloud Storage SDK clients rather than complete disk adapters. Disks with equivalent client construction config share the expensive client pool while retaining their own bucket, root, visibility, and callback behavior. Pooled disks expose raw internals only through borrow-scoped `withClient()`, `withDriver()`, and `withAdapter()` callbacks.
+Hypervel pools S3 and Google Cloud Storage SDK clients rather than complete disk adapters. Disks with equivalent client construction config share the expensive client pool while retaining their own bucket, root, visibility, and callback behavior. FTP and SFTP disks are pooled whole, because each adapter holds one connection that concurrent requests must not share. Pooled disks expose raw internals only through borrow-scoped `withClient()`, `withDriver()`, and `withAdapter()` callbacks.
On read-through disks, raw `listContents()` results from pooled, S3 and Google Cloud Storage sides are fully loaded instead of streamed.
diff --git a/src/filesystem/src/FilesystemManager.php b/src/filesystem/src/FilesystemManager.php
index 6faf4d01d4..bce535685a 100644
--- a/src/filesystem/src/FilesystemManager.php
+++ b/src/filesystem/src/FilesystemManager.php
@@ -105,7 +105,7 @@ class FilesystemManager implements FactoryContract
/**
* The array of drivers which will be wrapped as pool proxies.
*/
- protected array $poolableDrivers = ['s3', 'gcs'];
+ protected array $poolableDrivers = ['s3', 'gcs', 'ftp', 'sftp'];
/**
* Create a new filesystem manager instance.
diff --git a/tests/Filesystem/FilesystemManagerTest.php b/tests/Filesystem/FilesystemManagerTest.php
index b3c390381c..112e1ec938 100644
--- a/tests/Filesystem/FilesystemManagerTest.php
+++ b/tests/Filesystem/FilesystemManagerTest.php
@@ -1212,6 +1212,24 @@ public function testPoolableDriver(): void
$this->assertInstanceOf(FilesystemPoolProxy::class, $filesystem->disk('local'));
}
+ #[TestWith(['ftp'])]
+ #[TestWith(['sftp'])]
+ public function testConnectionHoldingDisksArePooledWhole(string $driver): void
+ {
+ // Each adapter keeps one connection, which concurrent coroutines must not share.
+ $filesystem = new FilesystemManager($this->getContainer([
+ 'disks' => [
+ 'remote' => [
+ 'driver' => $driver,
+ 'host' => 'files.example.com',
+ 'username' => 'hypervel',
+ ],
+ ],
+ ]));
+
+ $this->assertInstanceOf(FilesystemPoolProxy::class, $filesystem->disk('remote'));
+ }
+
public function testS3DisksWithTheSameClientConfigShareOneClientPoolAcrossBuckets(): void
{
$container = $this->getContainer([
From a1658048a909d63d055519d42cf49d66e05f91a1 Mon Sep 17 00:00:00 2001
From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com>
Date: Sat, 10 Oct 2026 13:36:55 +0000
Subject: [PATCH 14/18] Release filesystem pool connections after buffered
reads
Return pooled connections as soon as a stream is fully buffered, including bounded ranges over temporary streams. Keep live and unmarked decorated streams borrowed until close, without copying buffers or changing range seeking and size semantics.
Share the ownership decision across whole-driver and client pools. Preserve live S3 read-through coverage and document when downloads release their connections. Filesystem, Inertia, JWT and object-pool suites, formatting and static analysis pass.
---
src/docs/filesystem.md | 4 +-
src/filesystem/src/ClientPooledFilesystem.php | 10 +--
.../InteractsWithPooledFilesystem.php | 35 ++++++++-
src/filesystem/src/FilesystemAdapter.php | 15 +++-
src/filesystem/src/FilesystemPoolProxy.php | 10 +--
tests/Filesystem/AwsS3V3AdapterTest.php | 6 +-
.../Filesystem/ClientPooledFilesystemTest.php | 31 ++++++++
tests/Filesystem/FilesystemPoolProxyTest.php | 72 +++++++++++++++++++
8 files changed, 162 insertions(+), 21 deletions(-)
diff --git a/src/docs/filesystem.md b/src/docs/filesystem.md
index 2cc8906dc0..13f9a2940e 100644
--- a/src/docs/filesystem.md
+++ b/src/docs/filesystem.md
@@ -287,7 +287,7 @@ $result = Storage::disk('s3')->withClient(function ($client) {
});
```
-`Storage::forgetDisk()` only removes the manager's cached disk wrapper; an equivalent wrapper can continue using the shared pool. `Storage::purge()` removes the wrapper and closes its current pool, deriving the same pool identity even when the named disk has not been resolved yet or is composed from nested scoped disks. Other disks converging on that pool transparently create a fresh one on their next operation. Streams returned by `readStream()` or `readStreamRange()` retain their lease until the stream is closed or destroyed.
+`Storage::forgetDisk()` only removes the manager's cached disk wrapper; an equivalent wrapper can continue using the shared pool. `Storage::purge()` removes the wrapper and closes its current pool, deriving the same pool identity even when the named disk has not been resolved yet or is composed from nested scoped disks. Other disks converging on that pool transparently create a fresh one on their next operation. A stream returned by `readStream()` or `readStreamRange()` that still reads from its connection keeps that connection out of the pool until the stream is closed or destroyed. Fully buffered reads, including FTP and SFTP downloads, return the connection to the pool before you consume the stream.
S3 and Google Cloud Storage streams are read lazily by default, which keeps memory usage bounded and makes data available before the entire file has downloaded. This applies to `readStream()` and `readStreamRange()`; methods such as `get()` retain their normal behavior. Streaming requests close their HTTP connection after the read, so applications that open many small streams may prefer connection reuse and set the disk's `stream_reads` option to `false`.
@@ -785,7 +785,7 @@ Storage::disk('local')->moveToDisk(
);
```
-Transfers from pooled disks, including S3 and Google Cloud Storage, buffer the source before writing to the destination so the source's pool slot is available during the write. Buffering keeps up to 2 MB in memory per transfer, then uses PHP's system temporary directory; allow enough temporary disk space for large files and concurrent transfers. Local sources stream directly.
+Transfers from pooled disks, including S3 and Google Cloud Storage, buffer live source streams before writing to the destination so the source's pool slot is available during the write. Already buffered sources, including FTP and SFTP downloads, are reused without another copy. Buffering keeps up to 2 MB in memory per transfer, then uses PHP's system temporary directory; allow enough temporary disk space for large files and concurrent transfers. Local sources stream directly.
### Automatic Streaming
diff --git a/src/filesystem/src/ClientPooledFilesystem.php b/src/filesystem/src/ClientPooledFilesystem.php
index d24ac330ee..737512a63e 100644
--- a/src/filesystem/src/ClientPooledFilesystem.php
+++ b/src/filesystem/src/ClientPooledFilesystem.php
@@ -147,7 +147,7 @@ protected function buildStack(object $client): FilesystemAdapter
}
/**
- * Open a stream that owns its client lease until closure.
+ * Open a stream that retains its client lease while needed.
*
* @param Closure(FilesystemAdapter): mixed $operation
* @return null|resource
@@ -162,13 +162,7 @@ protected function leasedStream(Closure $operation): mixed
$lease->releaseAfterFailure($operationException);
}
- if (! is_resource($stream)) {
- $lease->release();
-
- return $stream;
- }
-
- return LeasedStream::wrap($stream, $lease);
+ return $this->releaseOrWrapStream($stream, $lease);
}
/**
diff --git a/src/filesystem/src/Concerns/InteractsWithPooledFilesystem.php b/src/filesystem/src/Concerns/InteractsWithPooledFilesystem.php
index 6c77c80675..7fcc69ad1a 100644
--- a/src/filesystem/src/Concerns/InteractsWithPooledFilesystem.php
+++ b/src/filesystem/src/Concerns/InteractsWithPooledFilesystem.php
@@ -11,13 +11,16 @@
use Hypervel\Contracts\Filesystem\Filesystem as FilesystemContract;
use Hypervel\Filesystem\AwsS3V3Adapter;
use Hypervel\Filesystem\FileResponseBuilder;
+use Hypervel\Filesystem\FilesystemAdapter;
use Hypervel\Filesystem\FilesystemOperatorAdapter;
use Hypervel\Filesystem\GoogleCloudStorageAdapter;
+use Hypervel\Filesystem\LeasedStream;
use Hypervel\Http\File;
use Hypervel\Http\Request;
use Hypervel\Http\UploadedFile;
use Hypervel\Image\Image;
use Hypervel\Image\ImageException;
+use Hypervel\ObjectPool\Lease;
use Hypervel\Support\Traits\Conditionable;
use League\Flysystem\FilesystemOperator;
use Psr\Http\Message\StreamInterface;
@@ -624,12 +627,42 @@ public function buildTemporaryUploadUrlsUsing(?Closure $callback): void
abstract protected function invoke(string $method, array $parameters): mixed;
/**
- * Open a stream that retains its lease until closure.
+ * Open a stream that retains any required lease until closure.
*
* @return null|resource
*/
abstract protected function leasedStream(Closure $operation): mixed;
+ /**
+ * Release buffered reads immediately and retain the lease for live streams.
+ *
+ * @param null|resource $stream
+ * @return null|resource
+ */
+ protected function releaseOrWrapStream(mixed $stream, Lease $lease): mixed
+ {
+ if (! is_resource($stream)) {
+ $lease->release();
+
+ return $stream;
+ }
+
+ $metadata = stream_get_meta_data($stream);
+
+ // Range wrappers are marked at construction; other decorators can report
+ // a temporary buffer while still reading from a live connection.
+ if (in_array($metadata['stream_type'], ['TEMP', 'MEMORY'], true)
+ || ($metadata['stream_type'] === 'user-space'
+ && (stream_context_get_options($stream)['hypervel'][FilesystemAdapter::BUFFERED_STREAM_CONTEXT_OPTION] ?? false) === true)
+ ) {
+ $lease->release();
+
+ return $stream;
+ }
+
+ return LeasedStream::wrap($stream, $lease);
+ }
+
/**
* Run a callback with an accessor result from a borrowed filesystem.
*/
diff --git a/src/filesystem/src/FilesystemAdapter.php b/src/filesystem/src/FilesystemAdapter.php
index 3351b0372f..079fc1f9ba 100644
--- a/src/filesystem/src/FilesystemAdapter.php
+++ b/src/filesystem/src/FilesystemAdapter.php
@@ -67,6 +67,13 @@ class FilesystemAdapter implements CloudFilesystemContract
__call as macroCall;
}
+ /**
+ * The stream context option identifying a range over an independent buffer.
+ *
+ * @internal
+ */
+ public const string BUFFERED_STREAM_CONTEXT_OPTION = 'buffered';
+
/**
* The filesystem configuration.
*/
@@ -735,8 +742,14 @@ public function readStreamRange(string $path, ?int $start, ?int $end): mixed
$psrStream = Utils::streamFor($stream);
$limitedStream = new LimitStream($psrStream, $length, $psrStream->tell());
+ $range = StreamWrapper::getResource($limitedStream);
+
+ if (in_array(stream_get_meta_data($stream)['stream_type'], ['TEMP', 'MEMORY'], true)) {
+ // Only mark this new wrapper; a driver's stream may share the default context.
+ stream_context_set_option($range, 'hypervel', self::BUFFERED_STREAM_CONTEXT_OPTION, true);
+ }
- return StreamWrapper::getResource($limitedStream);
+ return $range;
} catch (CanceledException $exception) {
if (is_resource($stream)) {
fclose($stream);
diff --git a/src/filesystem/src/FilesystemPoolProxy.php b/src/filesystem/src/FilesystemPoolProxy.php
index ac0e4af125..11ad4945c3 100644
--- a/src/filesystem/src/FilesystemPoolProxy.php
+++ b/src/filesystem/src/FilesystemPoolProxy.php
@@ -62,7 +62,7 @@ protected function configureBorrowed(object $object): void
}
/**
- * Open a stream that owns its whole-driver lease until closure.
+ * Open a stream that retains its whole-driver lease while needed.
*
* @param Closure(FilesystemContract): mixed $operation
* @return null|resource
@@ -78,13 +78,7 @@ protected function leasedStream(Closure $operation): mixed
$lease->releaseAfterFailure($operationException);
}
- if (! is_resource($stream)) {
- $lease->release();
-
- return $stream;
- }
-
- return LeasedStream::wrap($stream, $lease);
+ return $this->releaseOrWrapStream($stream, $lease);
}
/**
diff --git a/tests/Filesystem/AwsS3V3AdapterTest.php b/tests/Filesystem/AwsS3V3AdapterTest.php
index 60bee29c3d..2f2af753fd 100644
--- a/tests/Filesystem/AwsS3V3AdapterTest.php
+++ b/tests/Filesystem/AwsS3V3AdapterTest.php
@@ -309,7 +309,11 @@ public function testReadThroughRangesPreserveNativeRequestsAndTheOuterFailurePol
function (CommandInterface $request) use (&$command): Result {
$command = $request;
- return new Result(['Body' => Utils::streamFor('range')]);
+ [$reader, $writer] = stream_socket_pair(STREAM_PF_UNIX, STREAM_SOCK_STREAM, STREAM_IPPROTO_IP);
+ fwrite($writer, 'range');
+ fclose($writer);
+
+ return new Result(['Body' => Utils::streamFor($reader)]);
},
$failure,
]);
diff --git a/tests/Filesystem/ClientPooledFilesystemTest.php b/tests/Filesystem/ClientPooledFilesystemTest.php
index b3a19c81ec..46aef5cfdf 100644
--- a/tests/Filesystem/ClientPooledFilesystemTest.php
+++ b/tests/Filesystem/ClientPooledFilesystemTest.php
@@ -399,6 +399,37 @@ function (object $client) use (&$releaseCalls): void {
$this->assertSame(1, $releaseCalls);
}
+ public function testBufferedReadStreamReleasesTheClientBeforeConsumption(): void
+ {
+ $clientCreations = 0;
+ $stackCreations = 0;
+ $releaseCalls = 0;
+ $buffer = fopen('php://temp', 'w+b');
+ fwrite($buffer, 'buffered contents');
+ rewind($buffer);
+
+ $stack = m::mock(FilesystemAdapter::class);
+ $stack->shouldReceive('readStream')->once()->with('file.txt')->andReturn($buffer);
+ $disk = $this->disk(
+ $clientCreations,
+ $stackCreations,
+ static function (object $client) use (&$releaseCalls): void {
+ ++$releaseCalls;
+ },
+ static fn (object $client): FilesystemAdapter => $stack,
+ );
+
+ try {
+ $stream = $disk->readStream('file.txt');
+
+ $this->assertSame(0, $this->pools->get('filesystem:test')->getBorrowedCount());
+ $this->assertSame(1, $releaseCalls);
+ $this->assertSame('buffered contents', stream_get_contents($stream));
+ } finally {
+ fclose($buffer);
+ }
+ }
+
public function testNonResourceReadStreamResultReleasesImmediately(): void
{
$clientCreations = 0;
diff --git a/tests/Filesystem/FilesystemPoolProxyTest.php b/tests/Filesystem/FilesystemPoolProxyTest.php
index aa1d1a7d25..e971cbcfb7 100644
--- a/tests/Filesystem/FilesystemPoolProxyTest.php
+++ b/tests/Filesystem/FilesystemPoolProxyTest.php
@@ -6,6 +6,10 @@
use BadMethodCallException;
use Closure;
+use GuzzleHttp\Psr7\CachingStream;
+use GuzzleHttp\Psr7\LimitStream;
+use GuzzleHttp\Psr7\PumpStream;
+use GuzzleHttp\Psr7\StreamWrapper;
use Hypervel\Context\RequestContext;
use Hypervel\Contracts\Filesystem\Filesystem as FilesystemContract;
use Hypervel\Filesystem\FilesystemAdapter;
@@ -20,6 +24,7 @@
use Hypervel\Testbench\TestCase;
use Hypervel\Testing\ParallelTesting;
use League\Flysystem\Filesystem;
+use League\Flysystem\FilesystemOperator;
use League\Flysystem\Local\LocalFilesystemAdapter;
use League\Flysystem\UnableToReadFile;
use Mockery as m;
@@ -240,6 +245,73 @@ function (object $filesystem) use (&$releaseCalls): void {
$this->assertSame(1, $releaseCalls);
}
+ #[DataProvider('bufferedReadProvider')]
+ public function testBufferedReadsReleaseTheWholeDriverImmediately(string $uri, ?int $start, ?int $end, string $expected): void
+ {
+ $driver = m::mock(FilesystemOperator::class);
+ $driver->shouldReceive('readStream')->once()->with('file.txt')->andReturnUsing(static function () use ($uri): mixed {
+ $stream = fopen($uri, 'w+b');
+ fwrite($stream, '0123456789');
+ rewind($stream);
+
+ return $stream;
+ });
+ $proxy = $this->proxy(fn (): FilesystemAdapter => new FilesystemAdapter($driver, $this->adapter));
+ $stream = $start === null
+ ? $proxy->readStream('file.txt')
+ : $proxy->readStreamRange('file.txt', $start, $end);
+
+ try {
+ $this->assertSame(0, $this->pools->get('filesystem:driver')->getBorrowedCount());
+ $this->assertSame(1, $this->pools->get('filesystem:driver')->getIdleCount());
+ $this->assertSame(0, ftell($stream));
+ $this->assertSame(strlen($expected), fstat($stream)['size']);
+ $this->assertSame($expected, stream_get_contents($stream));
+ rewind($stream);
+ $this->assertSame($expected, stream_get_contents($stream));
+ } finally {
+ fclose($stream);
+ }
+ }
+
+ /**
+ * Provide buffered streams and byte ranges.
+ *
+ * @return array
+ */
+ public static function bufferedReadProvider(): array
+ {
+ return [
+ 'temporary stream' => ['php://temp', null, null, '0123456789'],
+ 'memory stream' => ['php://memory', null, null, '0123456789'],
+ 'range spilled to disk' => ['php://temp/maxmemory:1', 3, 5, '345'],
+ ];
+ }
+
+ public function testCachedLiveRangeKeepsTheWholeDriverBorrowedUntilClose(): void
+ {
+ $reads = 0;
+ $source = new PumpStream(static function (int $length) use (&$reads): string|false {
+ return ++$reads === 1 ? 'remote bytes' : false;
+ });
+ $range = StreamWrapper::getResource(new LimitStream(new CachingStream($source), 3));
+ $driver = m::mock(FilesystemOperator::class);
+ $driver->shouldReceive('readStream')->once()->with('file.txt')->andReturn($range);
+ $proxy = $this->proxy(fn (): FilesystemAdapter => new FilesystemAdapter($driver, $this->adapter));
+ $stream = $proxy->readStream('file.txt');
+
+ try {
+ $this->assertSame(0, $reads);
+ $this->assertSame(1, $this->pools->get('filesystem:driver')->getBorrowedCount());
+ $this->assertSame('rem', stream_get_contents($stream));
+ $this->assertSame(1, $reads);
+ } finally {
+ fclose($stream);
+ }
+
+ $this->assertSame(0, $this->pools->get('filesystem:driver')->getBorrowedCount());
+ }
+
public function testOperatorReadsPropagateFailuresAndReleaseTheWholeDriver(): void
{
$proxy = $this->proxy(fn (): FilesystemAdapter => $this->filesystem());
From 83aaa6ae79501ee6f1ea8f7aa919ddff2b7c3010 Mon Sep 17 00:00:00 2001
From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com>
Date: Sat, 10 Oct 2026 13:36:55 +0000
Subject: [PATCH 15/18] Build Inertia back redirects before storing the current
location
An empty Inertia response without a Referer could redirect back to itself after previous-location tracking overwrote the session URL. Store the visit after constructing the redirect, while preserving the original controller response for partial-reload detection and application eligibility overrides.
Add a regression for empty visits without a referrer. Middleware tests, formatting and static analysis pass.
---
src/inertia/src/Middleware.php | 5 ++++-
tests/Inertia/MiddlewareTest.php | 17 +++++++++++++++++
2 files changed, 21 insertions(+), 1 deletion(-)
diff --git a/src/inertia/src/Middleware.php b/src/inertia/src/Middleware.php
index 3490197049..eb2e48dbba 100644
--- a/src/inertia/src/Middleware.php
+++ b/src/inertia/src/Middleware.php
@@ -176,7 +176,7 @@ public function handle(Request $request, Closure $next): Response
return $response;
}
- $this->storeCurrentUrl($request, $response);
+ $originalResponse = $response;
if ($request->method() === 'GET' && $request->header(Header::VERSION, '') !== Inertia::getVersion()) {
$response = $this->onVersionChange($request, $response);
@@ -186,6 +186,9 @@ public function handle(Request $request, Closure $next): Response
$response = $this->onEmptyResponse($request, $response);
}
+ // Back redirects must read the previous location before this visit replaces it.
+ $this->storeCurrentUrl($request, $originalResponse);
+
if ($response->getStatusCode() === 302 && in_array($request->method(), ['PUT', 'PATCH', 'DELETE'], true)) {
$response->setStatusCode(303);
}
diff --git a/tests/Inertia/MiddlewareTest.php b/tests/Inertia/MiddlewareTest.php
index f0c24d75db..d85278e257 100644
--- a/tests/Inertia/MiddlewareTest.php
+++ b/tests/Inertia/MiddlewareTest.php
@@ -550,6 +550,23 @@ public function testInertiaVisitsAreNotStoredAsThePreviousUrlAndRouteByDefault()
$this->assertPreviousLocation($this->baseUrl . '/initial', 'initial');
}
+ public function testEmptyInertiaVisitsRedirectToThePreviousLocationWithoutAReferrer(): void
+ {
+ config()->set('inertia.store_previous_url', true);
+
+ $this->preparePreviousLocationEndpoints();
+
+ Route::middleware([StartSession::class, Middleware::class])
+ ->get('/empty', fn (): BaseResponse => response(''));
+
+ $this->get('/initial')->assertOk();
+
+ $this->get('/empty', [
+ 'X-Inertia' => 'true',
+ 'X-Requested-With' => 'XMLHttpRequest',
+ ])->assertRedirect('/initial');
+ }
+
public function testInertiaPrefetchVisitsAreNotStoredAsThePreviousUrlAndRoute(): void
{
config()->set('inertia.store_previous_url', true);
From 03a4a53b5aabf005188c9a5a4149cef6fde06a0d Mon Sep 17 00:00:00 2001
From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com>
Date: Sat, 10 Oct 2026 13:36:55 +0000
Subject: [PATCH 16/18] Report blocked Inertia SSR health checks as unhealthy
Treat destination-policy failures as failed health checks while keeping connection failures distinct for graceful shutdown. A blocked health check must not report a successful stop.
Cover both policy exception types through health and shutdown commands, and assert captured Artisan output as well as raw output. Command tests and affected suites pass.
---
src/inertia/src/Ssr/HttpGateway.php | 5 ++++-
tests/Inertia/Commands/StopSsrTest.php | 28 +++++++++++++++++++++++++-
2 files changed, 31 insertions(+), 2 deletions(-)
diff --git a/src/inertia/src/Ssr/HttpGateway.php b/src/inertia/src/Ssr/HttpGateway.php
index e35108fb5e..2ecf877bc8 100644
--- a/src/inertia/src/Ssr/HttpGateway.php
+++ b/src/inertia/src/Ssr/HttpGateway.php
@@ -8,6 +8,7 @@
use Hypervel\Contracts\Events\Dispatcher;
use Hypervel\Foundation\Http\Middleware\Concerns\ExcludesPaths;
use Hypervel\Http\Client\ConnectionException;
+use Hypervel\Http\Client\HttpClientException;
use Hypervel\Http\Client\PendingRequest;
use Hypervel\Http\Client\RequestException;
use Hypervel\Http\Request;
@@ -258,7 +259,9 @@ public function checkHealth(): bool
try {
return $pendingRequest->get($this->getProductionUrl('/health'))->successful();
- } catch (RequestException) {
+ } catch (ConnectionException $exception) {
+ throw $exception;
+ } catch (HttpClientException) {
return false;
}
}
diff --git a/tests/Inertia/Commands/StopSsrTest.php b/tests/Inertia/Commands/StopSsrTest.php
index 3ff12b6dba..bcbff8e15c 100644
--- a/tests/Inertia/Commands/StopSsrTest.php
+++ b/tests/Inertia/Commands/StopSsrTest.php
@@ -4,10 +4,13 @@
namespace Hypervel\Tests\Inertia\Commands;
+use Hypervel\Http\Client\Destinations\DestinationPolicyException;
+use Hypervel\Http\Client\Destinations\DisallowedDestinationException;
use Hypervel\Http\Client\Request;
use Hypervel\Inertia\Ssr\HttpGateway;
use Hypervel\Support\Facades\Http;
use Hypervel\Tests\Inertia\TestCase;
+use PHPUnit\Framework\Attributes\TestWith;
class StopSsrTest extends TestCase
{
@@ -80,7 +83,30 @@ public function testTheResponseBodyIsNotPrintedToTheConsole(): void
$this->expectOutputString('');
- $this->artisan('inertia:stop-ssr')->run();
+ $this->artisan('inertia:stop-ssr')
+ ->doesntExpectOutputToContain('Hello from another service')
+ ->run();
+ }
+
+ #[TestWith([DisallowedDestinationException::class])]
+ #[TestWith([DestinationPolicyException::class])]
+ public function testHealthChecksFailWhenTheSsrDestinationIsBlocked(string $exceptionClass): void
+ {
+ Http::fake([
+ $this->healthUrl => static fn (): never => throw new $exceptionClass('Blocked SSR destination.'),
+ ]);
+
+ $this->artisan('inertia:check-ssr')
+ ->expectsOutput('Inertia SSR server is not running.')
+ ->assertExitCode(1);
+
+ $this->artisan('inertia:stop-ssr')
+ ->expectsOutput('Unable to connect to Inertia SSR server.')
+ ->assertExitCode(1);
+
+ $this->artisan('inertia:stop-ssr', ['--graceful' => true])
+ ->expectsOutput('Unable to connect to Inertia SSR server.')
+ ->assertExitCode(1);
}
public function testSucceedsWhenTheSsrServerStops(): void
From 93f89343fa35af8e66d4b20f8c0ecda5ccd104a3 Mon Sep 17 00:00:00 2001
From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com>
Date: Sat, 10 Oct 2026 13:36:55 +0000
Subject: [PATCH 17/18] Cache class metadata for Inertia big-integer encoding
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Avoid repeating reflection over each object class hierarchy when preserving big integers. Retain one boolean per encountered class, never payload values or object instances, and clear the map through the existing response test cleanup.
Extend the arbitrary-object test to verify changed values across responses. Clarify opt-in examples and the reserved client marker. Encoder prototype measurements show 26–29% savings for DTO payloads with a fixed 392-byte increase for the tested one- and two-class maps. Props tests, affected suites, formatting and static analysis pass.
---
src/docs/frontend.md | 8 +++++++-
src/inertia/src/PreservesBigIntegers.php | 17 +++++++++++++++--
src/inertia/src/Response.php | 1 +
tests/Inertia/PropsResolverTest.php | 6 ++++++
4 files changed, 29 insertions(+), 3 deletions(-)
diff --git a/src/docs/frontend.md b/src/docs/frontend.md
index 4453e50e93..ed4c7c6548 100644
--- a/src/docs/frontend.md
+++ b/src/docs/frontend.md
@@ -177,18 +177,24 @@ Big integer support is disabled by default. You may enable it for every response
INERTIA_PRESERVE_BIG_INTEGERS=true
```
-You may also enable it for a single response using the `preserveBigIntegers` method. Passing `false` opts a single response out when the option is enabled:
+You may also enable it for a single response using the `preserveBigIntegers` method:
```php
return Inertia::render('orders/show', [
'order' => $order,
])->preserveBigIntegers();
+```
+
+Passing `false` opts a single response out when the option is enabled:
+```php
return Inertia::render('reports/index', $props)->preserveBigIntegers(false);
```
Only integers outside the safe range become a `BigInt`, so the same prop may arrive as a number or a `BigInt`, depending on its value. Flash data receives the same treatment as props. Arrays, models, collections, API resources, `JsonSerializable` values, and the public properties of your own classes are inspected for large integers, while built-in PHP classes such as `DateTime` are left untouched.
+When enabled, Inertia reserves objects with a string `$bigint` property as integer markers. Avoid that shape in your own props and flash data, since the client converts the entire object to a `BigInt`.
+
A `BigInt` submitted through the router, a form, or Precognition is sent as its digits, so your controller receives a numeric string that the `integer` validation rule and the request's `integer` method handle as usual. The `useHttp` hook does not convert `BigInt` values, so convert them to strings before sending them.
Inertia's testing helpers turn big integers back into integers, so you may assert against the same values you passed to the response:
diff --git a/src/inertia/src/PreservesBigIntegers.php b/src/inertia/src/PreservesBigIntegers.php
index cbf7f45a7b..04cde1e864 100644
--- a/src/inertia/src/PreservesBigIntegers.php
+++ b/src/inertia/src/PreservesBigIntegers.php
@@ -30,6 +30,13 @@ trait PreservesBigIntegers
*/
protected bool $preserveBigIntegers = false;
+ /**
+ * Whether each encountered class serializes as its public properties.
+ *
+ * @var array
+ */
+ protected static array $plainJsonClasses = [];
+
/**
* Wrap big integers only when this response opted in.
*/
@@ -137,12 +144,18 @@ protected function hasPlainJsonRepresentation(object $value): bool
return true;
}
+ $cached = static::$plainJsonClasses[$value::class] ?? null;
+
+ if ($cached !== null) {
+ return $cached;
+ }
+
foreach ([$value::class, ...class_parents($value)] as $class) {
if ((new ReflectionClass($class))->isInternal()) {
- return false;
+ return static::$plainJsonClasses[$value::class] = false;
}
}
- return true;
+ return static::$plainJsonClasses[$value::class] = true;
}
}
diff --git a/src/inertia/src/Response.php b/src/inertia/src/Response.php
index 61aba99833..00a786ff87 100644
--- a/src/inertia/src/Response.php
+++ b/src/inertia/src/Response.php
@@ -312,5 +312,6 @@ protected function finishUrlWithTrailingSlash(string $url): string
public static function flushState(): void
{
static::flushMacros();
+ static::$plainJsonClasses = [];
}
}
diff --git a/tests/Inertia/PropsResolverTest.php b/tests/Inertia/PropsResolverTest.php
index 4e6eb5adb6..dbbf10b928 100644
--- a/tests/Inertia/PropsResolverTest.php
+++ b/tests/Inertia/PropsResolverTest.php
@@ -1464,6 +1464,12 @@ public function testBigIntegersInsideArbitraryObjectsAreWrappedWhenEnabled(): vo
$this->assertSame($marker, $page['props']['nested']['money']->cents);
$this->assertSame($marker, $page['props']['wrapped']->money->cents);
$this->assertSame($marker, $page['props']['collection']['id']);
+
+ $money->cents = 900719925474099989;
+
+ $page = $this->makePage(Request::create('/'), ['money' => $money], preserveBigIntegers: true);
+
+ $this->assertSame(['$bigint' => '900719925474099989'], $page['props']['money']->cents);
}
public function testSelfReferencingObjectsDoNotRecurseForever(): void
From 2913864493609fd6160eb501b28c467d32e0d48b Mon Sep 17 00:00:00 2001
From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com>
Date: Sat, 10 Oct 2026 13:36:56 +0000
Subject: [PATCH 18/18] Accept dependency-specific causes for malformed JWT
dates
Lcobucci JWT 5.6.1 reports malformed registered dates through InvalidTokenStructure rather than TypeError. Assert the public token exception, message and retained Throwable cause without depending on an internal exception class.
All malformed-date cases pass with both dependency versions 5.6.0 and 5.6.1. Production decoding behavior is unchanged.
---
tests/Jwt/Providers/LcobucciTest.php | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/tests/Jwt/Providers/LcobucciTest.php b/tests/Jwt/Providers/LcobucciTest.php
index 1e05754d43..4ad821191b 100644
--- a/tests/Jwt/Providers/LcobucciTest.php
+++ b/tests/Jwt/Providers/LcobucciTest.php
@@ -28,7 +28,7 @@
use Lcobucci\JWT\Validation\Constraint\SignedWith;
use PHPUnit\Framework\Attributes\DataProvider;
use SensitiveParameterValue;
-use TypeError;
+use Throwable;
class LcobucciTest extends TestCase
{
@@ -337,7 +337,7 @@ public function testMalformedRegisteredDatesAreReportedAsInvalidTokens(string $c
$this->fail('Expected the malformed registered date to be rejected.');
} catch (TokenInvalidException $exception) {
$this->assertStringStartsWith('Could not decode token:', $exception->getMessage());
- $this->assertInstanceOf(TypeError::class, $exception->getPrevious());
+ $this->assertInstanceOf(Throwable::class, $exception->getPrevious());
}
}