From a305c96ad3ad1a28b326ba433f4831faac4b255c Mon Sep 17 00:00:00 2001
From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com>
Date: Wed, 21 Oct 2026 07:57:31 +0000
Subject: [PATCH 01/15] Add exception response observers before final
customization
Allow packages to inspect the prepared exception response without replacing the application response callback. Invoke boot-registered observers after request-local response callbacks and before respondUsing, ignoring observer return values.
Expose beforeResponding through exception configuration and the facade, document its worker lifetime, and cover ordering, response identity, arguments, and ignored return values in the handler tests. This lets metadata integrations observe the actual error status before an Inertia error page renders.
Validation: formatting and source/types analysis passed; FoundationExceptionsHandlerTest passed with 78 tests and 230 assertions. The complete framework run had no behavioral failures; its sole facade metadata failure was corrected and verified separately.
---
src/docs/errors.md | 18 +++++++++
.../src/Configuration/Exceptions.php | 17 ++++++++
src/foundation/src/Exceptions/Handler.php | 26 +++++++++++++
src/support/src/Facades/Exceptions.php | 1 +
.../FoundationExceptionsHandlerTest.php | 39 +++++++++++++++++++
5 files changed, 101 insertions(+)
diff --git a/src/docs/errors.md b/src/docs/errors.md
index 3615b75495..2f23d09db9 100644
--- a/src/docs/errors.md
+++ b/src/docs/errors.md
@@ -322,6 +322,24 @@ use Symfony\Component\HttpFoundation\Response;
})
```
+If a package or application needs to inspect the prepared response without replacing your `respond` callback, register a `beforeResponding` callback during boot:
+
+```php
+use Hypervel\Foundation\Configuration\Exceptions;
+use Hypervel\Support\Facades\Log;
+use Symfony\Component\HttpFoundation\Response;
+
+->withExceptions(function (Exceptions $exceptions): void {
+ $exceptions->beforeResponding(function (Response $response): void {
+ Log::debug('Exception response prepared.', [
+ 'status' => $response->getStatusCode(),
+ ]);
+ });
+})
+```
+
+These callbacks run in registration order, just before your `respond` callback. They receive the response, exception, and request; their return values are ignored. Callbacks are shared by every request in the worker, so read request-specific values inside the callback. Packages may register one from a service provider's `boot` method using the `Hypervel\Support\Facades\Exceptions::beforeResponding()` facade method.
+
### Reportable and Renderable Exceptions
diff --git a/src/foundation/src/Configuration/Exceptions.php b/src/foundation/src/Configuration/Exceptions.php
index 52b59540c5..29f88678ab 100644
--- a/src/foundation/src/Configuration/Exceptions.php
+++ b/src/foundation/src/Configuration/Exceptions.php
@@ -8,9 +8,11 @@
use Hypervel\Foundation\Exceptions\Handler;
use Hypervel\Foundation\Exceptions\ReportableHandler;
use Hypervel\Http\Client\RequestException;
+use Hypervel\Http\Request;
use Hypervel\Support\Arr;
use InvalidArgumentException;
use Psr\Log\LogLevel;
+use Symfony\Component\HttpFoundation\Response;
use Throwable;
class Exceptions
@@ -83,6 +85,21 @@ public function respond(callable $using): static
return $this;
}
+ /**
+ * Register a callback before final exception response customization.
+ *
+ * Boot-only. The callback is shared by every request in the worker.
+ * Callback return values are ignored.
+ *
+ * @param callable(Response, Throwable, Request): mixed $callback
+ */
+ public function beforeResponding(callable $callback): static
+ {
+ $this->handler->beforeResponding($callback);
+
+ return $this;
+ }
+
/**
* Specify the callback that should be used to throttle reportable exceptions.
*
diff --git a/src/foundation/src/Exceptions/Handler.php b/src/foundation/src/Exceptions/Handler.php
index c36b02c1ed..87723491f6 100644
--- a/src/foundation/src/Exceptions/Handler.php
+++ b/src/foundation/src/Exceptions/Handler.php
@@ -154,6 +154,13 @@ class Handler implements ExceptionHandlerContract
*/
protected $finalizeResponseCallback;
+ /**
+ * The callbacks invoked before final response customization.
+ *
+ * @var array
+ */
+ protected array $beforeRespondingCallbacks = [];
+
/**
* The registered exception mappings.
*
@@ -847,11 +854,30 @@ protected function finalizeRenderedResponse(Request $request, SymfonyResponse $r
}
}
+ foreach ($this->beforeRespondingCallbacks as $callback) {
+ $callback($response, $e, $request);
+ }
+
return $this->finalizeResponseCallback
? call_user_func($this->finalizeResponseCallback, $response, $e, $request)
: $response;
}
+ /**
+ * Register a callback before final exception response customization.
+ *
+ * Boot-only. The callback is shared by every request in the worker.
+ * Callback return values are ignored.
+ *
+ * @param callable(SymfonyResponse, Throwable, Request): mixed $callback
+ */
+ public function beforeResponding(callable $callback): static
+ {
+ $this->beforeRespondingCallbacks[] = Closure::fromCallable($callback);
+
+ return $this;
+ }
+
/**
* Register a callback to be called after an HTTP error response is rendered.
*/
diff --git a/src/support/src/Facades/Exceptions.php b/src/support/src/Facades/Exceptions.php
index beb7ad978f..436a4b659c 100644
--- a/src/support/src/Facades/Exceptions.php
+++ b/src/support/src/Facades/Exceptions.php
@@ -11,6 +11,7 @@
/**
* @method static void afterResponse(callable $callback)
+ * @method static \Hypervel\Foundation\Exceptions\Handler beforeResponding(callable $callback)
* @method static array buildContextForException(Throwable $e)
* @method static \Hypervel\Foundation\Exceptions\Handler buildContextUsing(\Closure $contextCallback)
* @method static array contextForException(Throwable $e)
diff --git a/tests/Foundation/FoundationExceptionsHandlerTest.php b/tests/Foundation/FoundationExceptionsHandlerTest.php
index 02f43ff245..7e31e4a0fb 100644
--- a/tests/Foundation/FoundationExceptionsHandlerTest.php
+++ b/tests/Foundation/FoundationExceptionsHandlerTest.php
@@ -21,8 +21,10 @@
use Hypervel\Database\RecordsNotFoundException;
use Hypervel\Engine\Channel;
use Hypervel\Foundation\Application;
+use Hypervel\Foundation\Configuration\Exceptions as ExceptionsConfiguration;
use Hypervel\Foundation\Exceptions\Handler;
use Hypervel\Foundation\Testing\Concerns\InteractsWithExceptionHandling;
+use Hypervel\Http\Exceptions\HttpResponseException;
use Hypervel\Http\RedirectResponse;
use Hypervel\Http\Request;
use Hypervel\RateLimiter\AdmissionPolicy;
@@ -1572,6 +1574,43 @@ public function testAfterResponseCallbacks(): void
$this->assertSame('true', $response->headers->get('X-After-Error'));
}
+ public function testBeforeRespondingObservesThePreparedResponseBeforeFinalCustomization(): void
+ {
+ $original = new SymfonyResponse('', 404);
+ $prepared = new SymfonyResponse('', 403);
+ $final = new SymfonyResponse('', 401);
+ $exception = new HttpResponseException($original);
+ $calls = [];
+
+ $this->handler->afterResponse(function (SymfonyResponse $response) use ($original, $prepared, &$calls): SymfonyResponse {
+ $this->assertSame($original, $response);
+ $calls[] = 'afterResponse';
+
+ return $prepared;
+ });
+
+ (new ExceptionsConfiguration($this->handler))->beforeResponding(
+ function (SymfonyResponse $response, Throwable $error, Request $request) use ($prepared, $exception, &$calls): SymfonyResponse {
+ $this->assertSame($prepared, $response);
+ $this->assertSame($exception, $error);
+ $this->assertSame($this->request, $request);
+ $calls[] = 'beforeResponding';
+
+ return new SymfonyResponse('', 500);
+ }
+ );
+
+ $this->handler->respondUsing(function (SymfonyResponse $response) use ($prepared, $final, &$calls): SymfonyResponse {
+ $this->assertSame($prepared, $response);
+ $calls[] = 'respondUsing';
+
+ return $final;
+ });
+
+ $this->assertSame($final, $this->handler->render($this->request, $exception));
+ $this->assertSame(['afterResponse', 'beforeResponding', 'respondUsing'], $calls);
+ }
+
protected function getConfig(array $config = []): Repository
{
return new Repository(array_merge([
From 2f1170e74967abcbe57164efa2cab34b81dc2bb7 Mon Sep 17 00:00:00 2001
From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com>
Date: Wed, 21 Oct 2026 07:57:37 +0000
Subject: [PATCH 02/15] Report invalid JSON correctly for plain Symfony
responses
Read the optional response exception through null coalescing before rethrowing it. Plain Symfony responses have no exception property, so invalid JSON previously raised an undefined-property error instead of the intended assertion failure.
Keep stored exception identity and the existing response proxy behavior unchanged. Add an exact-message regression alongside the stored-exception coverage.
Validation: TestResponseTest passed with 259 tests and 1,030 assertions; formatting and source/types analysis passed.
---
src/testing/src/TestResponse.php | 4 ++--
tests/Testing/TestResponseTest.php | 11 +++++++++++
2 files changed, 13 insertions(+), 2 deletions(-)
diff --git a/src/testing/src/TestResponse.php b/src/testing/src/TestResponse.php
index c25eb12574..102ac9676b 100644
--- a/src/testing/src/TestResponse.php
+++ b/src/testing/src/TestResponse.php
@@ -1111,8 +1111,8 @@ public function decodeResponseJson(): AssertableJsonString
// JSON permits only space, tab, line feed, and carriage return around a value.
if ($decodedResponse === null && trim($content, " \t\n\r") !== 'null') {
- if ($this->exception) {
- throw $this->exception;
+ if ($exception = $this->exception ?? null) {
+ throw $exception;
}
PHPUnit::withResponse($this)->fail('Invalid JSON was returned from the route.');
diff --git a/tests/Testing/TestResponseTest.php b/tests/Testing/TestResponseTest.php
index 37ed9b36a3..64f17dd4c0 100644
--- a/tests/Testing/TestResponseTest.php
+++ b/tests/Testing/TestResponseTest.php
@@ -37,6 +37,7 @@
use stdClass;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
use Symfony\Component\HttpFoundation\Cookie;
+use Symfony\Component\HttpFoundation\Response as SymfonyResponse;
use Symfony\Component\HttpFoundation\StreamedJsonResponse;
use Symfony\Component\HttpFoundation\StreamedResponse;
use Symfony\Component\VarDumper\VarDumper;
@@ -3688,6 +3689,16 @@ public function testDecodeResponseJsonRejectsNullLikeContentWithInvalidWhitespac
}
}
+ public function testDecodeResponseJsonReportsInvalidJsonForAPlainSymfonyResponse(): void
+ {
+ $response = TestResponse::fromBaseResponse(new SymfonyResponse('invalid'));
+
+ $this->expectException(AssertionFailedError::class);
+ $this->expectExceptionMessageIs('Invalid JSON was returned from the route.');
+
+ $response->decodeResponseJson();
+ }
+
public function testDecodeResponseJsonPreservesTheStoredResponseException(): void
{
$expected = new Exception('route failed');
From cc447a80ac29dbae1a8c729c8e3334b1c2050028 Mon Sep 17 00:00:00 2001
From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com>
Date: Wed, 21 Oct 2026 07:57:50 +0000
Subject: [PATCH 03/15] Port Head with coroutine-safe Blade and Inertia
integration
Port Laravel Head metadata builders, routing macros, structured data, facades, and rendering into the optional hypervel/head split package. Keep request data scoped and replicable while reusing worker-safe services and bounded metadata caches.
Add boot-registered runtime defaults, error, and canonical-origin resolvers for request-specific metadata. Preserve eager upstream configuration, isolate copied schemas, and retain lazy Inertia sharing and partial reload behavior. Decorate SSR responses without mutating their readonly contents or changing the JavaScript protocol, and forward gateway capabilities.
Correct canonical query and fragment handling, independent Open Graph fallback media, literal zero metadata, manifest replacement, title ownership, and shared schema-factory lookup. Capture actual exception response status before final customization. Wire split and development metadata, optional test cleanup, editor signatures, generated facade metadata, and PHPStan macro support using public reflection APIs.
Validation: formatting and complete source/types analysis passed, including routing and facade fixtures. Head passed 180 tests and 640 assertions; facade metadata checks passed 2 tests and 16,228 assertions. Package-mode and dogfood checks passed. The initial full framework run completed with one facade metadata failure, subsequently fixed and verified by focused checks.
---
composer.json | 4 +
phpstan.neon.dist | 2 +
phpstan.types.neon.dist | 2 +
src/head/LICENSE.md | 23 +
src/head/README.md | 12 +
src/head/_ide_helper.php | 464 ++++++++++++++++
src/head/composer.json | 73 +++
src/head/extension.neon | 5 +
src/head/src/Concerns/BuildsHead.php | 504 ++++++++++++++++++
src/head/src/CurrentHead.php | 69 +++
src/head/src/Enums/ImageType.php | 16 +
src/head/src/Enums/Media.php | 13 +
src/head/src/Enums/OfferAvailability.php | 29 +
src/head/src/Enums/OgType.php | 21 +
src/head/src/Enums/RobotsRule.php | 63 +++
src/head/src/Enums/TwitterCard.php | 13 +
src/head/src/ErrorPages.php | 80 +++
src/head/src/Exceptions/InvalidSchema.php | 11 +
src/head/src/Facades/Head.php | 102 ++++
src/head/src/Facades/Schema.php | 62 +++
src/head/src/HeadBuilder.php | 43 ++
src/head/src/HeadData.php | 118 ++++
src/head/src/HeadManager.php | 375 +++++++++++++
src/head/src/HeadServiceProvider.php | 81 +++
src/head/src/Inertia/Integration.php | 67 +++
src/head/src/Inertia/ShareHead.php | 39 ++
src/head/src/Inertia/UniqueHeadGateway.php | 104 ++++
src/head/src/PHPStan/RoutingMacros.php | 80 +++
src/head/src/PHPStan/WithHeadExtension.php | 65 +++
.../src/PHPStan/WithHeadMethodReflection.php | 155 ++++++
src/head/src/Rendering/HeadRenderer.php | 164 ++++++
src/head/src/Rendering/ResolvedHead.php | 169 ++++++
src/head/src/Rendering/TagRenderer.php | 221 ++++++++
src/head/src/Routing/HeadRouteMacros.php | 462 ++++++++++++++++
src/head/src/Routing/RouteAttributeParser.php | 192 +++++++
src/head/src/Schema/Article.php | 62 +++
src/head/src/Schema/BlogPosting.php | 12 +
src/head/src/Schema/Brand.php | 35 ++
src/head/src/Schema/Breadcrumbs.php | 43 ++
src/head/src/Schema/Event.php | 70 +++
src/head/src/Schema/Faq.php | 45 ++
src/head/src/Schema/GenericSchemaObject.php | 9 +
src/head/src/Schema/Offer.php | 36 ++
src/head/src/Schema/Organization.php | 35 ++
src/head/src/Schema/Person.php | 27 +
src/head/src/Schema/Product.php | 63 +++
src/head/src/Schema/SchemaFactory.php | 90 ++++
src/head/src/Schema/SchemaObject.php | 147 +++++
src/head/src/Schema/SchemaValidator.php | 68 +++
src/head/src/Schema/WebPage.php | 35 ++
src/head/src/Schema/WebSite.php | 27 +
src/head/src/SchemaType.php | 18 +
src/head/src/TagRegistry.php | 120 +++++
src/head/src/Tags/AlternateLinks.php | 113 ++++
src/head/src/Tags/Canonical.php | 216 ++++++++
src/head/src/Tags/Description.php | 33 ++
src/head/src/Tags/FeedLinks.php | 193 +++++++
src/head/src/Tags/GenericLinks.php | 263 +++++++++
src/head/src/Tags/GroupedTagBuilder.php | 17 +
src/head/src/Tags/MetaTags.php | 228 ++++++++
src/head/src/Tags/OpenGraph.php | 488 +++++++++++++++++
src/head/src/Tags/PaginationLinks.php | 114 ++++
src/head/src/Tags/PerformanceLinks.php | 438 +++++++++++++++
src/head/src/Tags/Robots.php | 87 +++
src/head/src/Tags/Schemas.php | 159 ++++++
src/head/src/Tags/StringTagBuilder.php | 76 +++
src/head/src/Tags/TagBuilder.php | 165 ++++++
src/head/src/Tags/Title.php | 142 +++++
src/head/src/Tags/Twitter.php | 263 +++++++++
.../src/PHPUnit/AfterEachTestSubscriber.php | 11 +
types/Head/Facades.php | 38 ++
types/Head/Route.php | 89 ++++
72 files changed, 7978 insertions(+)
create mode 100644 src/head/LICENSE.md
create mode 100644 src/head/README.md
create mode 100644 src/head/_ide_helper.php
create mode 100644 src/head/composer.json
create mode 100644 src/head/extension.neon
create mode 100644 src/head/src/Concerns/BuildsHead.php
create mode 100644 src/head/src/CurrentHead.php
create mode 100644 src/head/src/Enums/ImageType.php
create mode 100644 src/head/src/Enums/Media.php
create mode 100644 src/head/src/Enums/OfferAvailability.php
create mode 100644 src/head/src/Enums/OgType.php
create mode 100644 src/head/src/Enums/RobotsRule.php
create mode 100644 src/head/src/Enums/TwitterCard.php
create mode 100644 src/head/src/ErrorPages.php
create mode 100644 src/head/src/Exceptions/InvalidSchema.php
create mode 100644 src/head/src/Facades/Head.php
create mode 100644 src/head/src/Facades/Schema.php
create mode 100644 src/head/src/HeadBuilder.php
create mode 100644 src/head/src/HeadData.php
create mode 100644 src/head/src/HeadManager.php
create mode 100644 src/head/src/HeadServiceProvider.php
create mode 100644 src/head/src/Inertia/Integration.php
create mode 100644 src/head/src/Inertia/ShareHead.php
create mode 100644 src/head/src/Inertia/UniqueHeadGateway.php
create mode 100644 src/head/src/PHPStan/RoutingMacros.php
create mode 100644 src/head/src/PHPStan/WithHeadExtension.php
create mode 100644 src/head/src/PHPStan/WithHeadMethodReflection.php
create mode 100644 src/head/src/Rendering/HeadRenderer.php
create mode 100644 src/head/src/Rendering/ResolvedHead.php
create mode 100644 src/head/src/Rendering/TagRenderer.php
create mode 100644 src/head/src/Routing/HeadRouteMacros.php
create mode 100644 src/head/src/Routing/RouteAttributeParser.php
create mode 100644 src/head/src/Schema/Article.php
create mode 100644 src/head/src/Schema/BlogPosting.php
create mode 100644 src/head/src/Schema/Brand.php
create mode 100644 src/head/src/Schema/Breadcrumbs.php
create mode 100644 src/head/src/Schema/Event.php
create mode 100644 src/head/src/Schema/Faq.php
create mode 100644 src/head/src/Schema/GenericSchemaObject.php
create mode 100644 src/head/src/Schema/Offer.php
create mode 100644 src/head/src/Schema/Organization.php
create mode 100644 src/head/src/Schema/Person.php
create mode 100644 src/head/src/Schema/Product.php
create mode 100644 src/head/src/Schema/SchemaFactory.php
create mode 100644 src/head/src/Schema/SchemaObject.php
create mode 100644 src/head/src/Schema/SchemaValidator.php
create mode 100644 src/head/src/Schema/WebPage.php
create mode 100644 src/head/src/Schema/WebSite.php
create mode 100644 src/head/src/SchemaType.php
create mode 100644 src/head/src/TagRegistry.php
create mode 100644 src/head/src/Tags/AlternateLinks.php
create mode 100644 src/head/src/Tags/Canonical.php
create mode 100644 src/head/src/Tags/Description.php
create mode 100644 src/head/src/Tags/FeedLinks.php
create mode 100644 src/head/src/Tags/GenericLinks.php
create mode 100644 src/head/src/Tags/GroupedTagBuilder.php
create mode 100644 src/head/src/Tags/MetaTags.php
create mode 100644 src/head/src/Tags/OpenGraph.php
create mode 100644 src/head/src/Tags/PaginationLinks.php
create mode 100644 src/head/src/Tags/PerformanceLinks.php
create mode 100644 src/head/src/Tags/Robots.php
create mode 100644 src/head/src/Tags/Schemas.php
create mode 100644 src/head/src/Tags/StringTagBuilder.php
create mode 100644 src/head/src/Tags/TagBuilder.php
create mode 100644 src/head/src/Tags/Title.php
create mode 100644 src/head/src/Tags/Twitter.php
create mode 100644 types/Head/Facades.php
create mode 100644 types/Head/Route.php
diff --git a/composer.json b/composer.json
index 38bbbd8de5..ea66bd9eb1 100644
--- a/composer.json
+++ b/composer.json
@@ -177,6 +177,7 @@
"hypervel/foundation": "self.version",
"hypervel/grpc": "self.version",
"hypervel/hashing": "self.version",
+ "hypervel/head": "self.version",
"hypervel/horizon": "self.version",
"hypervel/http": "self.version",
"hypervel/http-server": "self.version",
@@ -266,6 +267,7 @@
"Hypervel\\Foundation\\": "src/foundation/src/",
"Hypervel\\Grpc\\": "src/grpc/src/",
"Hypervel\\Hashing\\": "src/hashing/src/",
+ "Hypervel\\Head\\": "src/head/src/",
"Hypervel\\Horizon\\": "src/horizon/src/",
"Hypervel\\HttpServer\\": "src/http-server/src/",
"Hypervel\\Http\\": "src/http/src/",
@@ -378,6 +380,7 @@
"hypervel": {
"aliases": {
"Feature": "Hypervel\\Pennant\\Feature",
+ "Head": "Hypervel\\Head\\Facades\\Head",
"Mcp": "Hypervel\\Mcp\\Facades\\Mcp",
"OpenTelemetry": "Hypervel\\OpenTelemetry\\Facades\\OpenTelemetry",
"Saloon": "Hypervel\\Saloon\\Facades\\Saloon",
@@ -401,6 +404,7 @@
"Hypervel\\Events\\EventServiceProvider",
"Hypervel\\Filesystem\\FilesystemServiceProvider",
"Hypervel\\Hashing\\HashingServiceProvider",
+ "Hypervel\\Head\\HeadServiceProvider",
"Hypervel\\Horizon\\HorizonServiceProvider",
"Hypervel\\Http\\HttpServiceProvider",
"Hypervel\\Image\\ImageServiceProvider",
diff --git a/phpstan.neon.dist b/phpstan.neon.dist
index df94d23afd..d69eab555a 100644
--- a/phpstan.neon.dist
+++ b/phpstan.neon.dist
@@ -6,6 +6,7 @@
includes:
- src/database/extension.neon
- src/foundation/extension.neon
+ - src/head/extension.neon
- src/scout/extension.neon
parameters:
@@ -27,6 +28,7 @@ parameters:
- vendor/swoole/ide-helper/src/swoole/Swoole/Server.php
excludePaths:
- %currentWorkingDirectory%/src/helper/*
+ - %currentWorkingDirectory%/src/head/_ide_helper.php
- %currentWorkingDirectory%/tests/*
- %currentWorkingDirectory%/src/testbench/hypervel/*
- %currentWorkingDirectory%/src/testbench/workbench/*
diff --git a/phpstan.types.neon.dist b/phpstan.types.neon.dist
index e36ba13bcf..8f75c3d250 100644
--- a/phpstan.types.neon.dist
+++ b/phpstan.types.neon.dist
@@ -1,6 +1,7 @@
includes:
- src/database/extension.neon
- src/foundation/extension.neon
+ - src/head/extension.neon
- src/scout/extension.neon
parameters:
@@ -9,6 +10,7 @@ parameters:
scanDirectories:
- src/database/src/PHPStan
- src/foundation/src/PHPStan
+ - src/head/src/PHPStan
- src/scout/src/PHPStan
paths:
- types
diff --git a/src/head/LICENSE.md b/src/head/LICENSE.md
new file mode 100644
index 0000000000..670aace448
--- /dev/null
+++ b/src/head/LICENSE.md
@@ -0,0 +1,23 @@
+The MIT License (MIT)
+
+Copyright (c) Taylor Otwell
+
+Copyright (c) Hypervel
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in
+all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+THE SOFTWARE.
\ No newline at end of file
diff --git a/src/head/README.md b/src/head/README.md
new file mode 100644
index 0000000000..6e4ccb69b1
--- /dev/null
+++ b/src/head/README.md
@@ -0,0 +1,12 @@
+Head for Hypervel
+===
+
+Documentation: https://hypervel.org/docs/head
+
+## Differences From Laravel
+
+Head configuration is shared for the worker lifetime. Register defaults and error pages during boot; use the runtime resolvers for request-specific values. See [runtime defaults](https://hypervel.org/docs/head#runtime-defaults).
+
+Livewire integration is not provided. Use Blade or Inertia for page rendering.
+
+Ported from: https://github.com/laravel/head
diff --git a/src/head/_ide_helper.php b/src/head/_ide_helper.php
new file mode 100644
index 0000000000..8a468d4ea5
--- /dev/null
+++ b/src/head/_ide_helper.php
@@ -0,0 +1,464 @@
+|string $title
+ * @param null|array|string|true $canonical
+ * @param null|array|RobotsRule|string $robots
+ * @param null|array $og
+ * @param null|array|string $ogImage
+ * @param null|array|string $ogVideo
+ * @param null|array|string $ogAudio
+ * @param null|array $twitter
+ * @param null|array|string $twitterImage
+ * @param null|array|string $preload
+ * @param null|array|string $prefetch
+ * @param null|array|string $preconnect
+ * @param null|array|string $dnsPrefetch
+ * @param null|array $alternates
+ * @param null|array|string $feed
+ * @param null|array|string $icon
+ * @param null|array|string $favicon
+ * @param null|array|string $appleTouchIcon
+ * @param null|array|string $appleTouchStartupImage
+ * @param null|array|string $maskIcon
+ * @param null|array|string $manifest
+ * @param null|array $schema
+ * @param null|array $meta
+ * @param null|array $link
+ * @param array $extensions
+ */
+ public function withHead(
+ string|array|null $title = null,
+ ?string $description = null,
+ string|array|true|null $canonical = null,
+ string|RobotsRule|array|null $robots = null,
+ ?string $themeColor = null,
+ ?string $applicationName = null,
+ ?string $colorScheme = null,
+ ?string $referrer = null,
+ ?string $viewport = null,
+ ?string $appleWebAppTitle = null,
+ ?bool $webAppCapable = null,
+ ?string $appleWebAppStatusBarStyle = null,
+ ?array $og = null,
+ string|array|null $ogImage = null,
+ string|array|null $ogVideo = null,
+ string|array|null $ogAudio = null,
+ ?array $twitter = null,
+ string|array|null $twitterImage = null,
+ string|array|null $preload = null,
+ string|array|null $prefetch = null,
+ string|array|null $preconnect = null,
+ string|array|null $dnsPrefetch = null,
+ ?array $alternates = null,
+ string|array|null $feed = null,
+ string|array|null $icon = null,
+ string|array|null $favicon = null,
+ string|array|null $appleTouchIcon = null,
+ string|array|null $appleTouchStartupImage = null,
+ string|array|null $maskIcon = null,
+ string|array|null $manifest = null,
+ ?array $schema = null,
+ ?array $meta = null,
+ ?array $link = null,
+ array $extensions = [],
+ ): RouteRegistrar {
+ }
+ }
+}
+
+namespace Hypervel\Routing {
+ use Hypervel\Head\Enums\RobotsRule;
+
+ class RouteRegistrar
+ {
+ /**
+ * Define head metadata for the route, stored through Hypervel's native
+ * route metadata API. Provided by hypervel/head.
+ *
+ * @param null|array|string $title
+ * @param null|array|string|true $canonical
+ * @param null|array|RobotsRule|string $robots
+ * @param null|array $og
+ * @param null|array|string $ogImage
+ * @param null|array|string $ogVideo
+ * @param null|array|string $ogAudio
+ * @param null|array $twitter
+ * @param null|array|string $twitterImage
+ * @param null|array|string $preload
+ * @param null|array|string $prefetch
+ * @param null|array|string $preconnect
+ * @param null|array|string $dnsPrefetch
+ * @param null|array $alternates
+ * @param null|array|string $feed
+ * @param null|array|string $icon
+ * @param null|array|string $favicon
+ * @param null|array|string $appleTouchIcon
+ * @param null|array|string $appleTouchStartupImage
+ * @param null|array|string $maskIcon
+ * @param null|array|string $manifest
+ * @param null|array $schema
+ * @param null|array $meta
+ * @param null|array $link
+ * @param array $extensions
+ */
+ public function withHead(
+ string|array|null $title = null,
+ ?string $description = null,
+ string|array|true|null $canonical = null,
+ string|RobotsRule|array|null $robots = null,
+ ?string $themeColor = null,
+ ?string $applicationName = null,
+ ?string $colorScheme = null,
+ ?string $referrer = null,
+ ?string $viewport = null,
+ ?string $appleWebAppTitle = null,
+ ?bool $webAppCapable = null,
+ ?string $appleWebAppStatusBarStyle = null,
+ ?array $og = null,
+ string|array|null $ogImage = null,
+ string|array|null $ogVideo = null,
+ string|array|null $ogAudio = null,
+ ?array $twitter = null,
+ string|array|null $twitterImage = null,
+ string|array|null $preload = null,
+ string|array|null $prefetch = null,
+ string|array|null $preconnect = null,
+ string|array|null $dnsPrefetch = null,
+ ?array $alternates = null,
+ string|array|null $feed = null,
+ string|array|null $icon = null,
+ string|array|null $favicon = null,
+ string|array|null $appleTouchIcon = null,
+ string|array|null $appleTouchStartupImage = null,
+ string|array|null $maskIcon = null,
+ string|array|null $manifest = null,
+ ?array $schema = null,
+ ?array $meta = null,
+ ?array $link = null,
+ array $extensions = [],
+ ): RouteRegistrar {
+ }
+ }
+}
+
+namespace Hypervel\Routing {
+ use Hypervel\Head\Enums\RobotsRule;
+
+ class Route
+ {
+ /**
+ * Define head metadata for the route, stored through Hypervel's native
+ * route metadata API. Provided by hypervel/head.
+ *
+ * @param null|array|string $title
+ * @param null|array|string|true $canonical
+ * @param null|array|RobotsRule|string $robots
+ * @param null|array $og
+ * @param null|array|string $ogImage
+ * @param null|array|string $ogVideo
+ * @param null|array|string $ogAudio
+ * @param null|array $twitter
+ * @param null|array|string $twitterImage
+ * @param null|array|string $preload
+ * @param null|array|string $prefetch
+ * @param null|array|string $preconnect
+ * @param null|array|string $dnsPrefetch
+ * @param null|array $alternates
+ * @param null|array|string $feed
+ * @param null|array|string $icon
+ * @param null|array|string $favicon
+ * @param null|array|string $appleTouchIcon
+ * @param null|array|string $appleTouchStartupImage
+ * @param null|array|string $maskIcon
+ * @param null|array|string $manifest
+ * @param null|array $schema
+ * @param null|array $meta
+ * @param null|array $link
+ * @param array $extensions
+ */
+ public function withHead(
+ string|array|null $title = null,
+ ?string $description = null,
+ string|array|true|null $canonical = null,
+ string|RobotsRule|array|null $robots = null,
+ ?string $themeColor = null,
+ ?string $applicationName = null,
+ ?string $colorScheme = null,
+ ?string $referrer = null,
+ ?string $viewport = null,
+ ?string $appleWebAppTitle = null,
+ ?bool $webAppCapable = null,
+ ?string $appleWebAppStatusBarStyle = null,
+ ?array $og = null,
+ string|array|null $ogImage = null,
+ string|array|null $ogVideo = null,
+ string|array|null $ogAudio = null,
+ ?array $twitter = null,
+ string|array|null $twitterImage = null,
+ string|array|null $preload = null,
+ string|array|null $prefetch = null,
+ string|array|null $preconnect = null,
+ string|array|null $dnsPrefetch = null,
+ ?array $alternates = null,
+ string|array|null $feed = null,
+ string|array|null $icon = null,
+ string|array|null $favicon = null,
+ string|array|null $appleTouchIcon = null,
+ string|array|null $appleTouchStartupImage = null,
+ string|array|null $maskIcon = null,
+ string|array|null $manifest = null,
+ ?array $schema = null,
+ ?array $meta = null,
+ ?array $link = null,
+ array $extensions = [],
+ ): Route {
+ }
+ }
+}
+
+namespace Hypervel\Routing {
+ use Hypervel\Head\Enums\RobotsRule;
+
+ class PendingResourceRegistration
+ {
+ /**
+ * Define head metadata for the route, stored through Hypervel's native
+ * route metadata API. Provided by hypervel/head.
+ *
+ * @param null|array|string $title
+ * @param null|array|string|true $canonical
+ * @param null|array|RobotsRule|string $robots
+ * @param null|array $og
+ * @param null|array|string $ogImage
+ * @param null|array|string $ogVideo
+ * @param null|array|string $ogAudio
+ * @param null|array $twitter
+ * @param null|array|string $twitterImage
+ * @param null|array|string $preload
+ * @param null|array|string $prefetch
+ * @param null|array|string $preconnect
+ * @param null|array|string $dnsPrefetch
+ * @param null|array $alternates
+ * @param null|array|string $feed
+ * @param null|array|string $icon
+ * @param null|array|string $favicon
+ * @param null|array|string $appleTouchIcon
+ * @param null|array|string $appleTouchStartupImage
+ * @param null|array|string $maskIcon
+ * @param null|array|string $manifest
+ * @param null|array $schema
+ * @param null|array $meta
+ * @param null|array $link
+ * @param array $extensions
+ */
+ public function withHead(
+ string|array|null $title = null,
+ ?string $description = null,
+ string|array|true|null $canonical = null,
+ string|RobotsRule|array|null $robots = null,
+ ?string $themeColor = null,
+ ?string $applicationName = null,
+ ?string $colorScheme = null,
+ ?string $referrer = null,
+ ?string $viewport = null,
+ ?string $appleWebAppTitle = null,
+ ?bool $webAppCapable = null,
+ ?string $appleWebAppStatusBarStyle = null,
+ ?array $og = null,
+ string|array|null $ogImage = null,
+ string|array|null $ogVideo = null,
+ string|array|null $ogAudio = null,
+ ?array $twitter = null,
+ string|array|null $twitterImage = null,
+ string|array|null $preload = null,
+ string|array|null $prefetch = null,
+ string|array|null $preconnect = null,
+ string|array|null $dnsPrefetch = null,
+ ?array $alternates = null,
+ string|array|null $feed = null,
+ string|array|null $icon = null,
+ string|array|null $favicon = null,
+ string|array|null $appleTouchIcon = null,
+ string|array|null $appleTouchStartupImage = null,
+ string|array|null $maskIcon = null,
+ string|array|null $manifest = null,
+ ?array $schema = null,
+ ?array $meta = null,
+ ?array $link = null,
+ array $extensions = [],
+ ): PendingResourceRegistration {
+ }
+ }
+}
+
+namespace Hypervel\Routing {
+ use Hypervel\Head\Enums\RobotsRule;
+
+ class PendingSingletonResourceRegistration
+ {
+ /**
+ * Define head metadata for the route, stored through Hypervel's native
+ * route metadata API. Provided by hypervel/head.
+ *
+ * @param null|array|string $title
+ * @param null|array|string|true $canonical
+ * @param null|array|RobotsRule|string $robots
+ * @param null|array $og
+ * @param null|array|string $ogImage
+ * @param null|array|string $ogVideo
+ * @param null|array|string $ogAudio
+ * @param null|array $twitter
+ * @param null|array|string $twitterImage
+ * @param null|array|string $preload
+ * @param null|array|string $prefetch
+ * @param null|array|string $preconnect
+ * @param null|array|string $dnsPrefetch
+ * @param null|array $alternates
+ * @param null|array|string $feed
+ * @param null|array|string $icon
+ * @param null|array|string $favicon
+ * @param null|array|string $appleTouchIcon
+ * @param null|array|string $appleTouchStartupImage
+ * @param null|array|string $maskIcon
+ * @param null|array|string $manifest
+ * @param null|array $schema
+ * @param null|array $meta
+ * @param null|array $link
+ * @param array $extensions
+ */
+ public function withHead(
+ string|array|null $title = null,
+ ?string $description = null,
+ string|array|true|null $canonical = null,
+ string|RobotsRule|array|null $robots = null,
+ ?string $themeColor = null,
+ ?string $applicationName = null,
+ ?string $colorScheme = null,
+ ?string $referrer = null,
+ ?string $viewport = null,
+ ?string $appleWebAppTitle = null,
+ ?bool $webAppCapable = null,
+ ?string $appleWebAppStatusBarStyle = null,
+ ?array $og = null,
+ string|array|null $ogImage = null,
+ string|array|null $ogVideo = null,
+ string|array|null $ogAudio = null,
+ ?array $twitter = null,
+ string|array|null $twitterImage = null,
+ string|array|null $preload = null,
+ string|array|null $prefetch = null,
+ string|array|null $preconnect = null,
+ string|array|null $dnsPrefetch = null,
+ ?array $alternates = null,
+ string|array|null $feed = null,
+ string|array|null $icon = null,
+ string|array|null $favicon = null,
+ string|array|null $appleTouchIcon = null,
+ string|array|null $appleTouchStartupImage = null,
+ string|array|null $maskIcon = null,
+ string|array|null $manifest = null,
+ ?array $schema = null,
+ ?array $meta = null,
+ ?array $link = null,
+ array $extensions = [],
+ ): PendingSingletonResourceRegistration {
+ }
+ }
+}
+
+namespace Hypervel\Support\Facades {
+ use Hypervel\Head\Enums\RobotsRule;
+ use Hypervel\Routing\RouteRegistrar;
+
+ class Route
+ {
+ /**
+ * Define head metadata for the route, stored through Hypervel's native
+ * route metadata API. Provided by hypervel/head.
+ *
+ * @param null|array|string $title
+ * @param null|array|string|true $canonical
+ * @param null|array|RobotsRule|string $robots
+ * @param null|array $og
+ * @param null|array|string $ogImage
+ * @param null|array|string $ogVideo
+ * @param null|array|string $ogAudio
+ * @param null|array $twitter
+ * @param null|array|string $twitterImage
+ * @param null|array|string $preload
+ * @param null|array|string $prefetch
+ * @param null|array|string $preconnect
+ * @param null|array|string $dnsPrefetch
+ * @param null|array $alternates
+ * @param null|array|string $feed
+ * @param null|array|string $icon
+ * @param null|array|string $favicon
+ * @param null|array|string $appleTouchIcon
+ * @param null|array|string $appleTouchStartupImage
+ * @param null|array|string $maskIcon
+ * @param null|array|string $manifest
+ * @param null|array $schema
+ * @param null|array $meta
+ * @param null|array $link
+ * @param array $extensions
+ */
+ public static function withHead(
+ string|array|null $title = null,
+ ?string $description = null,
+ string|array|true|null $canonical = null,
+ string|RobotsRule|array|null $robots = null,
+ ?string $themeColor = null,
+ ?string $applicationName = null,
+ ?string $colorScheme = null,
+ ?string $referrer = null,
+ ?string $viewport = null,
+ ?string $appleWebAppTitle = null,
+ ?bool $webAppCapable = null,
+ ?string $appleWebAppStatusBarStyle = null,
+ ?array $og = null,
+ string|array|null $ogImage = null,
+ string|array|null $ogVideo = null,
+ string|array|null $ogAudio = null,
+ ?array $twitter = null,
+ string|array|null $twitterImage = null,
+ string|array|null $preload = null,
+ string|array|null $prefetch = null,
+ string|array|null $preconnect = null,
+ string|array|null $dnsPrefetch = null,
+ ?array $alternates = null,
+ string|array|null $feed = null,
+ string|array|null $icon = null,
+ string|array|null $favicon = null,
+ string|array|null $appleTouchIcon = null,
+ string|array|null $appleTouchStartupImage = null,
+ string|array|null $maskIcon = null,
+ string|array|null $manifest = null,
+ ?array $schema = null,
+ ?array $meta = null,
+ ?array $link = null,
+ array $extensions = [],
+ ): RouteRegistrar {
+ }
+ }
+}
diff --git a/src/head/composer.json b/src/head/composer.json
new file mode 100644
index 0000000000..fde356bcde
--- /dev/null
+++ b/src/head/composer.json
@@ -0,0 +1,73 @@
+{
+ "name": "hypervel/head",
+ "description": "The Hypervel Head package.",
+ "license": "MIT",
+ "type": "library",
+ "keywords": [
+ "php",
+ "head",
+ "seo",
+ "swoole",
+ "hypervel"
+ ],
+ "authors": [
+ {
+ "name": "Albert Chen",
+ "email": "albert@hypervel.org"
+ },
+ {
+ "name": "Raj Siva-Rajah",
+ "homepage": "https://github.com/binaryfire"
+ }
+ ],
+ "support": {
+ "issues": "https://github.com/hypervel/components/issues",
+ "source": "https://github.com/hypervel/components"
+ },
+ "require": {
+ "php": "^8.4",
+ "hypervel/collections": "^0.4",
+ "hypervel/conditionable": "^0.4",
+ "hypervel/container": "^0.4",
+ "hypervel/context": "^0.4",
+ "hypervel/contracts": "^0.4",
+ "hypervel/foundation": "^0.4",
+ "hypervel/http": "^0.4",
+ "hypervel/macroable": "^0.4",
+ "hypervel/routing": "^0.4",
+ "hypervel/support": "^0.4",
+ "hypervel/view": "^0.4",
+ "psr/log": "^3.0",
+ "symfony/http-foundation": "^8.1",
+ "symfony/http-kernel": "^8.1"
+ },
+ "suggest": {
+ "hypervel/inertia": "Required for Inertia head management (^0.4)."
+ },
+ "autoload": {
+ "psr-4": {
+ "Hypervel\\Head\\": "src/"
+ }
+ },
+ "config": {
+ "sort-packages": true
+ },
+ "extra": {
+ "branch-alias": {
+ "dev-main": "0.4-dev"
+ },
+ "hypervel": {
+ "providers": [
+ "Hypervel\\Head\\HeadServiceProvider"
+ ],
+ "aliases": {
+ "Head": "Hypervel\\Head\\Facades\\Head"
+ }
+ },
+ "phpstan": {
+ "includes": [
+ "extension.neon"
+ ]
+ }
+ }
+}
diff --git a/src/head/extension.neon b/src/head/extension.neon
new file mode 100644
index 0000000000..392e16dc33
--- /dev/null
+++ b/src/head/extension.neon
@@ -0,0 +1,5 @@
+services:
+ -
+ class: Hypervel\Head\PHPStan\WithHeadExtension
+ tags:
+ - phpstan.broker.methodsClassReflectionExtension
diff --git a/src/head/src/Concerns/BuildsHead.php b/src/head/src/Concerns/BuildsHead.php
new file mode 100644
index 0000000000..1f8fc368f7
--- /dev/null
+++ b/src/head/src/Concerns/BuildsHead.php
@@ -0,0 +1,504 @@
+headData()->overlayBuilder(Title::make($title, prefix: $prefix, suffix: $suffix, exact: $exact));
+
+ return $this;
+ }
+
+ /**
+ * Set the page description.
+ */
+ public function description(string $description): static
+ {
+ $this->headData()->overlayBuilder(Description::make($description));
+
+ return $this;
+ }
+
+ /**
+ * Set the theme color for the given media.
+ */
+ public function themeColor(string $color, Media|string|null $media = null): static
+ {
+ return $this->meta('theme-color', $color, media: $media);
+ }
+
+ /**
+ * Set the application name.
+ */
+ public function applicationName(string $name): static
+ {
+ return $this->meta('application-name', $name);
+ }
+
+ /**
+ * Set the supported color schemes.
+ */
+ public function colorScheme(string $scheme): static
+ {
+ return $this->meta('color-scheme', $scheme);
+ }
+
+ /**
+ * Set the referrer policy.
+ */
+ public function referrer(string $policy): static
+ {
+ return $this->meta('referrer', $policy);
+ }
+
+ /**
+ * Set the viewport metadata.
+ */
+ public function viewport(string $content): static
+ {
+ return $this->meta('viewport', $content);
+ }
+
+ /**
+ * Set the Apple web app title.
+ */
+ public function appleWebAppTitle(string $title): static
+ {
+ return $this->meta('apple-mobile-web-app-title', $title);
+ }
+
+ /**
+ * Set whether the page supports web app mode.
+ */
+ public function webAppCapable(bool $capable = true): static
+ {
+ return $this->meta('mobile-web-app-capable', $capable ? 'yes' : 'no');
+ }
+
+ /**
+ * Set the Apple web app status bar style.
+ */
+ public function appleWebAppStatusBarStyle(string $style): static
+ {
+ return $this->meta('apple-mobile-web-app-status-bar-style', $style);
+ }
+
+ /**
+ * Set the canonical URL and its normalization options.
+ */
+ public function canonical(?string $url = null, ?bool $forceHttps = null, ?bool $trailingSlash = null): static
+ {
+ $this->headData()->overlayBuilder(Canonical::make($url, forceHttps: $forceHttps, trailingSlash: $trailingSlash));
+
+ return $this;
+ }
+
+ /**
+ * Set the robots directives.
+ *
+ * @param array|RobotsRule|string $directives
+ */
+ public function robots(string|RobotsRule|array $directives): static
+ {
+ $this->headData()->overlayBuilder(Robots::make($directives));
+
+ return $this;
+ }
+
+ /**
+ * Add a robots meta tag with the "all" directive, allowing search engines to index the page.
+ */
+ public function searchableByRobots(): static
+ {
+ return $this->robots(RobotsRule::All);
+ }
+
+ /**
+ * Add a robots meta tag with the "none" directive, preventing search engines from indexing the page.
+ */
+ public function hiddenFromRobots(): static
+ {
+ return $this->robots(RobotsRule::None);
+ }
+
+ /**
+ * Set Open Graph metadata.
+ */
+ public function og(
+ OgType|string|null $type = null,
+ ?string $title = null,
+ ?string $description = null,
+ ?string $url = null,
+ ?string $image = null,
+ ?string $video = null,
+ ?string $audio = null,
+ ?string $siteName = null,
+ ?string $locale = null,
+ ?string $determiner = null,
+ ): static {
+ $this->headData()->overlayBuilder(OpenGraph::make(
+ type: $type,
+ title: $title,
+ description: $description,
+ url: $url,
+ image: $image,
+ video: $video,
+ audio: $audio,
+ siteName: $siteName,
+ locale: $locale,
+ determiner: $determiner,
+ ));
+
+ return $this;
+ }
+
+ /**
+ * Add an Open Graph image.
+ */
+ public function ogImage(
+ string $url,
+ ?string $alt = null,
+ ?int $width = null,
+ ?int $height = null,
+ ImageType|string|null $type = null,
+ ?string $secureUrl = null,
+ ): static {
+ $this->headData()->builder(OpenGraph::class)->image($url, alt: $alt, width: $width, height: $height, type: $type, secureUrl: $secureUrl);
+
+ return $this;
+ }
+
+ /**
+ * Add an Open Graph video.
+ */
+ public function ogVideo(
+ string $url,
+ ?string $alt = null,
+ ?int $width = null,
+ ?int $height = null,
+ ?string $type = null,
+ ?string $secureUrl = null,
+ ): static {
+ $this->headData()->builder(OpenGraph::class)->video($url, alt: $alt, width: $width, height: $height, type: $type, secureUrl: $secureUrl);
+
+ return $this;
+ }
+
+ /**
+ * Add Open Graph audio.
+ */
+ public function ogAudio(string $url, ?string $type = null, ?string $secureUrl = null): static
+ {
+ $this->headData()->builder(OpenGraph::class)->audio($url, type: $type, secureUrl: $secureUrl);
+
+ return $this;
+ }
+
+ /**
+ * Set Twitter card metadata.
+ */
+ public function twitter(
+ TwitterCard|string|null $card = null,
+ ?string $site = null,
+ ?string $creator = null,
+ ?string $title = null,
+ ?string $description = null,
+ ?string $image = null,
+ ): static {
+ $this->headData()->overlayBuilder(Twitter::make(
+ card: $card,
+ site: $site,
+ creator: $creator,
+ title: $title,
+ description: $description,
+ image: $image,
+ ));
+
+ return $this;
+ }
+
+ /**
+ * Set the Twitter card image.
+ */
+ public function twitterImage(string $url, ?string $alt = null): static
+ {
+ $this->headData()->builder(Twitter::class)->image($url, alt: $alt);
+
+ return $this;
+ }
+
+ /**
+ * Add a resource preload hint.
+ */
+ public function preload(string $href, ?string $as = null, bool|string|null $crossorigin = null, ImageType|string|null $type = null, Media|string|null $media = null): static
+ {
+ $this->headData()->builder(PerformanceLinks::class)->preload($href, as: $as, crossorigin: $crossorigin, type: $type, media: $media);
+
+ return $this;
+ }
+
+ /**
+ * Add an asset preload hint.
+ */
+ public function preloadAsset(string $path, ?string $as = null, bool|string|null $crossorigin = null, ImageType|string|null $type = null, Media|string|null $media = null): static
+ {
+ $this->headData()->builder(PerformanceLinks::class)->preloadAsset($path, as: $as, crossorigin: $crossorigin, type: $type, media: $media);
+
+ return $this;
+ }
+
+ /**
+ * Add a resource prefetch hint.
+ */
+ public function prefetch(string $href, ?string $as = null): static
+ {
+ $this->headData()->builder(PerformanceLinks::class)->prefetch($href, as: $as);
+
+ return $this;
+ }
+
+ /**
+ * Add an asset prefetch hint.
+ */
+ public function prefetchAsset(string $path, ?string $as = null): static
+ {
+ $this->headData()->builder(PerformanceLinks::class)->prefetchAsset($path, as: $as);
+
+ return $this;
+ }
+
+ /**
+ * Add a connection hint for an origin.
+ */
+ public function preconnect(string $href, bool|string|null $crossorigin = null): static
+ {
+ $this->headData()->builder(PerformanceLinks::class)->preconnect($href, crossorigin: $crossorigin);
+
+ return $this;
+ }
+
+ /**
+ * Add a DNS prefetch hint.
+ */
+ public function dnsPrefetch(string $href): static
+ {
+ $this->headData()->builder(PerformanceLinks::class)->dnsPrefetch($href);
+
+ return $this;
+ }
+
+ /**
+ * Add rel=prev/next links from a paginator instance.
+ *
+ * @param Paginator $paginator
+ */
+ public function paginate(Paginator $paginator): static
+ {
+ $this->headData()->overlayBuilder(PaginationLinks::fromPaginator($paginator));
+
+ return $this;
+ }
+
+ /**
+ * Add localized alternate URLs.
+ *
+ * @param array $alternates
+ */
+ public function alternates(array $alternates): static
+ {
+ $this->headData()->overlayBuilder(new AlternateLinks($alternates));
+
+ return $this;
+ }
+
+ /**
+ * Add a feed link.
+ */
+ public function feed(string $href, string $title, string $type = 'rss'): static
+ {
+ $this->headData()->builder(FeedLinks::class)->feed($href, $title, $type);
+
+ return $this;
+ }
+
+ /**
+ * Add a JSON-LD schema object to the page.
+ *
+ * @param array|callable(SchemaFactory): (array|SchemaObject)|SchemaObject $schema
+ */
+ public function schema(SchemaObject|array|callable $schema): static
+ {
+ if (is_callable($schema)) {
+ $schema = $schema($this->schemaFactory());
+ }
+
+ $this->headData()->builder(Schemas::class)->schema($schema);
+
+ return $this;
+ }
+
+ /**
+ * Add a named or property-based meta tag.
+ */
+ public function meta(string $key, string $content, ?bool $property = null, Media|string|null $media = null): static
+ {
+ $this->headData()->builder(MetaTags::class)->tag($key, $content, $property, $media);
+
+ return $this;
+ }
+
+ /**
+ * Add a link with optional attributes.
+ *
+ * @param array $attributes
+ */
+ public function link(string $rel, string $href, array $attributes = []): static
+ {
+ $this->headData()->builder(GenericLinks::class)->link($rel, $href, $attributes);
+
+ return $this;
+ }
+
+ /**
+ * Add an icon candidate.
+ */
+ public function icon(string $href, ImageType|string|null $type = null, ?string $sizes = null, Media|string|null $media = null): static
+ {
+ return $this->link('icon', $href, static::linkAttributes([
+ 'type' => $type instanceof ImageType ? $type->value : $type,
+ 'sizes' => $sizes,
+ 'media' => $media instanceof Media ? $media->value : $media,
+ ]));
+ }
+
+ /**
+ * Add a favicon candidate.
+ */
+ public function favicon(string $href, ImageType|string|null $type = null, ?string $sizes = null, Media|string|null $media = null): static
+ {
+ return $this->icon($href, type: $type, sizes: $sizes, media: $media);
+ }
+
+ /**
+ * Add an Apple touch icon.
+ */
+ public function appleTouchIcon(string $href, ?string $sizes = null): static
+ {
+ return $this->link('apple-touch-icon', $href, static::linkAttributes([
+ 'sizes' => $sizes,
+ ]));
+ }
+
+ /**
+ * Add a pinned-tab mask icon.
+ */
+ public function maskIcon(string $href, ?string $color = null): static
+ {
+ return $this->link('mask-icon', $href, static::linkAttributes([
+ 'color' => $color,
+ ]));
+ }
+
+ /**
+ * Set the web app manifest link.
+ */
+ public function manifest(string $href = '/site.webmanifest', bool|string|null $crossorigin = null): static
+ {
+ return $this->link('manifest', $href, static::linkAttributes([
+ 'crossorigin' => $crossorigin,
+ ]));
+ }
+
+ /**
+ * Add an Apple startup image.
+ */
+ public function appleTouchStartupImage(string $href, Media|string|null $media = null): static
+ {
+ return $this->link('apple-touch-startup-image', $href, static::linkAttributes([
+ 'media' => $media instanceof Media ? $media->value : $media,
+ ]));
+ }
+
+ /**
+ * Set progressive web app metadata.
+ */
+ public function pwa(
+ string $name,
+ string $manifest = '/site.webmanifest',
+ ?string $themeColor = null,
+ ?string $appleTouchIcon = null,
+ ?string $appleTouchIconSizes = '180x180',
+ ?string $appleWebAppStatusBarStyle = null,
+ ): static {
+ $this->applicationName($name)
+ ->appleWebAppTitle($name)
+ ->webAppCapable()
+ ->manifest($manifest);
+
+ if (! is_null($themeColor)) {
+ $this->themeColor($themeColor);
+ }
+
+ if (! is_null($appleTouchIcon)) {
+ $this->appleTouchIcon($appleTouchIcon, sizes: $appleTouchIconSizes);
+ }
+
+ if (! is_null($appleWebAppStatusBarStyle)) {
+ $this->appleWebAppStatusBarStyle($appleWebAppStatusBarStyle);
+ }
+
+ return $this;
+ }
+
+ /**
+ * Remove unspecified link attributes.
+ *
+ * @param array $attributes
+ * @return array
+ */
+ protected static function linkAttributes(array $attributes): array
+ {
+ return array_filter($attributes, fn (bool|float|int|string|null $value): bool => ! is_null($value));
+ }
+}
diff --git a/src/head/src/CurrentHead.php b/src/head/src/CurrentHead.php
new file mode 100644
index 0000000000..36fbba3593
--- /dev/null
+++ b/src/head/src/CurrentHead.php
@@ -0,0 +1,69 @@
+flush();
+ }
+
+ /**
+ * Get the current head data.
+ */
+ public function data(): HeadData
+ {
+ return $this->data;
+ }
+
+ /**
+ * Get the current response status.
+ */
+ public function status(): ?int
+ {
+ return $this->status;
+ }
+
+ /**
+ * Set the current response status.
+ */
+ public function setStatus(int $status): void
+ {
+ $this->status = $status;
+ }
+
+ /**
+ * Clear the current page data and response status.
+ */
+ public function flush(): void
+ {
+ $this->data = new HeadData;
+ $this->status = null;
+ }
+
+ /**
+ * Copy the current state for an independent coroutine.
+ */
+ public function replicate(): static
+ {
+ $replica = clone $this;
+ $replica->data = $this->data->replicate();
+
+ return $replica;
+ }
+}
diff --git a/src/head/src/Enums/ImageType.php b/src/head/src/Enums/ImageType.php
new file mode 100644
index 0000000000..ae7e1bb542
--- /dev/null
+++ b/src/head/src/Enums/ImageType.php
@@ -0,0 +1,16 @@
+value;
+ }
+}
diff --git a/src/head/src/Enums/OgType.php b/src/head/src/Enums/OgType.php
new file mode 100644
index 0000000000..0092efc639
--- /dev/null
+++ b/src/head/src/Enums/OgType.php
@@ -0,0 +1,21 @@
+ */
+ protected array $statuses = [];
+
+ /**
+ * Create the error page definitions.
+ *
+ * @param Closure(callable(HeadBuilder): mixed): HeadData $define
+ */
+ public function __construct(
+ protected TagRegistry $registry,
+ protected Closure $define,
+ ) {
+ $this->defaults = new HeadData;
+ }
+
+ /**
+ * Define head metadata for every error page, using either named
+ * route-attribute values or a single head builder callback.
+ *
+ * Boot-only. These defaults are shared by every request in the worker.
+ */
+ public function defaults(mixed ...$head): static
+ {
+ $this->defaults = $this->apply($this->defaults, $head);
+
+ return $this;
+ }
+
+ /**
+ * Define head metadata for a specific error status, using either named
+ * route-attribute values or a single head builder callback.
+ *
+ * Boot-only. These definitions are shared by every request in the worker.
+ */
+ public function status(int $status, mixed ...$head): static
+ {
+ $this->statuses[$status] = $this->apply(new HeadData, $head);
+
+ return $this;
+ }
+
+ /**
+ * Get the merged error head data for a status, or null when none is defined.
+ */
+ public function forStatus(int $status): ?HeadData
+ {
+ if (! isset($this->statuses[$status]) && $this->defaults->isEmpty()) {
+ return null;
+ }
+
+ return $this->defaults->merge($this->statuses[$status] ?? new HeadData);
+ }
+
+ /**
+ * Apply a callback or route attributes to the given head data.
+ *
+ * @param array $head
+ */
+ protected function apply(HeadData $base, array $head): HeadData
+ {
+ if (array_keys($head) === [0] && $head[0] instanceof Closure) {
+ return $base->merge(($this->define)($head[0]));
+ }
+
+ return RouteAttributeParser::apply($base, RouteAttributeParser::arguments($head), $this->registry);
+ }
+}
diff --git a/src/head/src/Exceptions/InvalidSchema.php b/src/head/src/Exceptions/InvalidSchema.php
new file mode 100644
index 0000000000..69d62eceeb
--- /dev/null
+++ b/src/head/src/Exceptions/InvalidSchema.php
@@ -0,0 +1,11 @@
+ $alternates)
+ * @method static \Hypervel\Head\HeadManager appleTouchIcon(string $href, string|null $sizes = null)
+ * @method static \Hypervel\Head\HeadManager appleTouchStartupImage(string $href, \Hypervel\Head\Enums\Media|string|null $media = null)
+ * @method static \Hypervel\Head\HeadManager appleWebAppStatusBarStyle(string $style)
+ * @method static \Hypervel\Head\HeadManager appleWebAppTitle(string $title)
+ * @method static \Hypervel\Head\HeadManager applicationName(string $name)
+ * @method static \Hypervel\Head\HeadManager canonical(string|null $url = null, bool|null $forceHttps = null, bool|null $trailingSlash = null)
+ * @method static \Hypervel\Head\HeadManager colorScheme(string $scheme)
+ * @method static \Hypervel\Head\HeadManager description(string $description)
+ * @method static \Hypervel\Head\HeadManager dnsPrefetch(string $href)
+ * @method static \Hypervel\Head\HeadManager favicon(string $href, \Hypervel\Head\Enums\ImageType|string|null $type = null, string|null $sizes = null, \Hypervel\Head\Enums\Media|string|null $media = null)
+ * @method static \Hypervel\Head\HeadManager feed(string $href, string $title, string $type = 'rss')
+ * @method static \Hypervel\Head\HeadManager flush()
+ * @method static void flushMacros()
+ * @method static bool hasMacro(string $name)
+ * @method static \Hypervel\Head\HeadManager hiddenFromRobots()
+ * @method static \Hypervel\Head\HeadManager icon(string $href, \Hypervel\Head\Enums\ImageType|string|null $type = null, string|null $sizes = null, \Hypervel\Head\Enums\Media|string|null $media = null)
+ * @method static \Hypervel\Head\HeadManager inertia(string $prop = 'head')
+ * @method static string inertiaProp()
+ * @method static \Hypervel\Head\HeadManager link(string $rel, string $href, array $attributes = [])
+ * @method static void macro(string $name, callable|object $macro)
+ * @method static \Hypervel\Head\HeadManager manifest(string $href = '/site.webmanifest', string|bool|null $crossorigin = null)
+ * @method static \Hypervel\Head\HeadManager maskIcon(string $href, string|null $color = null)
+ * @method static \Hypervel\Head\HeadManager meta(string $key, string $content, bool|null $property = null, \Hypervel\Head\Enums\Media|string|null $media = null)
+ * @method static void mixin(object $mixin, bool $replace = true)
+ * @method static \Hypervel\Head\HeadManager og(\Hypervel\Head\Enums\OgType|string|null $type = null, string|null $title = null, string|null $description = null, string|null $url = null, string|null $image = null, string|null $video = null, string|null $audio = null, string|null $siteName = null, string|null $locale = null, string|null $determiner = null)
+ * @method static \Hypervel\Head\HeadManager ogAudio(string $url, string|null $type = null, string|null $secureUrl = null)
+ * @method static \Hypervel\Head\HeadManager ogImage(string $url, string|null $alt = null, int|null $width = null, int|null $height = null, \Hypervel\Head\Enums\ImageType|string|null $type = null, string|null $secureUrl = null)
+ * @method static \Hypervel\Head\HeadManager ogVideo(string $url, string|null $alt = null, int|null $width = null, int|null $height = null, string|null $type = null, string|null $secureUrl = null)
+ * @method static \Hypervel\Head\HeadManager paginate(\Hypervel\Contracts\Pagination\Paginator $paginator)
+ * @method static \Hypervel\Head\HeadManager preconnect(string $href, string|bool|null $crossorigin = null)
+ * @method static \Hypervel\Head\HeadManager prefetch(string $href, string|null $as = null)
+ * @method static \Hypervel\Head\HeadManager prefetchAsset(string $path, string|null $as = null)
+ * @method static \Hypervel\Head\HeadManager preload(string $href, string|null $as = null, string|bool|null $crossorigin = null, \Hypervel\Head\Enums\ImageType|string|null $type = null, \Hypervel\Head\Enums\Media|string|null $media = null)
+ * @method static \Hypervel\Head\HeadManager preloadAsset(string $path, string|null $as = null, string|bool|null $crossorigin = null, \Hypervel\Head\Enums\ImageType|string|null $type = null, \Hypervel\Head\Enums\Media|string|null $media = null)
+ * @method static \Hypervel\Head\HeadManager pwa(string $name, string $manifest = '/site.webmanifest', string|null $themeColor = null, string|null $appleTouchIcon = null, string|null $appleTouchIconSizes = '180x180', string|null $appleWebAppStatusBarStyle = null)
+ * @method static \Hypervel\Head\HeadManager referrer(string $policy)
+ * @method static \Hypervel\Support\HtmlString render(int|null $status = null)
+ * @method static \Hypervel\Support\HtmlString renderForView(int|null $status = null)
+ * @method static \Hypervel\Head\HeadManager robots(array|\Hypervel\Head\Enums\RobotsRule|string $directives)
+ * @method static \Hypervel\Head\HeadManager searchableByRobots()
+ * @method static \Hypervel\Head\HeadManager status(int $status)
+ * @method static \Hypervel\Head\HeadManager themeColor(string $color, \Hypervel\Head\Enums\Media|string|null $media = null)
+ * @method static \Hypervel\Head\HeadManager title(string $title, string|null $prefix = null, string|null $suffix = null, bool|null $exact = null)
+ * @method static array toArray(int|null $status = null)
+ * @method static array toElements(int|null $status = null)
+ * @method static string toHtml(int|null $status = null)
+ * @method static array toInertiaElements(int|null $status = null)
+ * @method static \Hypervel\Head\HeadManager twitter(\Hypervel\Head\Enums\TwitterCard|string|null $card = null, string|null $site = null, string|null $creator = null, string|null $title = null, string|null $description = null, string|null $image = null)
+ * @method static \Hypervel\Head\HeadManager twitterImage(string $url, string|null $alt = null)
+ * @method static \Hypervel\Head\HeadManager viewport(string $content)
+ * @method static \Hypervel\Head\HeadManager webAppCapable(bool $capable = true)
+ *
+ * @see \Hypervel\Head\HeadManager
+ *
+ * @mixin HeadManager
+ */
+class Head extends Facade
+{
+ /**
+ * Get methods that should be excluded from the generated facade docblock.
+ *
+ * The HeadManager mixin preserves callback, class-string and generic types
+ * that generated method tags flatten.
+ *
+ * @return array
+ */
+ protected static function ignoredFacadeDocumenterMethods(): array
+ {
+ return [
+ 'defaults',
+ 'errors',
+ 'inertiaGlobals',
+ 'resolveDefaultsUsing',
+ 'resolveErrorsUsing',
+ 'resolveCanonicalOriginUsing',
+ 'schema',
+ 'extend',
+ 'when',
+ 'unless',
+ ];
+ }
+
+ /**
+ * Get the registered name of the component.
+ */
+ protected static function getFacadeAccessor(): string
+ {
+ return 'head';
+ }
+}
diff --git a/src/head/src/Facades/Schema.php b/src/head/src/Facades/Schema.php
new file mode 100644
index 0000000000..fb9d65cc18
--- /dev/null
+++ b/src/head/src/Facades/Schema.php
@@ -0,0 +1,62 @@
+
+ */
+ protected static function ignoredFacadeDocumenterMethods(): array
+ {
+ return ['make', 'register'];
+ }
+
+ /**
+ * Get the registered name of the component.
+ */
+ protected static function getFacadeAccessor(): string
+ {
+ return 'head.schema';
+ }
+}
diff --git a/src/head/src/HeadBuilder.php b/src/head/src/HeadBuilder.php
new file mode 100644
index 0000000000..bf2ea812a9
--- /dev/null
+++ b/src/head/src/HeadBuilder.php
@@ -0,0 +1,43 @@
+data;
+ }
+
+ /**
+ * Get the schema factory used to resolve schema callbacks.
+ */
+ protected function schemaFactory(): SchemaFactory
+ {
+ // Directly constructed builders must also see registered custom schema types.
+ return $this->schemaFactory ??= Container::getInstance()->make(SchemaFactory::class);
+ }
+}
diff --git a/src/head/src/HeadData.php b/src/head/src/HeadData.php
new file mode 100644
index 0000000000..cd806fed2b
--- /dev/null
+++ b/src/head/src/HeadData.php
@@ -0,0 +1,118 @@
+, TagBuilder> */
+ protected array $builders = [];
+
+ /**
+ * Get all tag builders.
+ *
+ * @return array, TagBuilder>
+ */
+ public function builders(): array
+ {
+ return $this->builders;
+ }
+
+ /**
+ * Get an existing tag builder.
+ *
+ * @param class-string $builder
+ */
+ public function get(string $builder): ?TagBuilder
+ {
+ return $this->builders[$builder] ?? null;
+ }
+
+ /**
+ * Resolve a mutable tag builder instance, creating and storing it on first use.
+ *
+ * @template TBuilder of TagBuilder
+ *
+ * @param class-string $class
+ * @return TBuilder
+ */
+ public function builder(string $class): TagBuilder
+ {
+ /** @var TBuilder */
+ return $this->builders[$class] ??= new $class;
+ }
+
+ /**
+ * Overlay the given builder onto any existing builder of the same type.
+ */
+ public function overlayBuilder(TagBuilder $builder): static
+ {
+ $class = $builder::class;
+ $this->builders[$class] = $builder->overlayOn($this->builders[$class] ?? null);
+
+ return $this;
+ }
+
+ /**
+ * Merge the given data over this data into a new instance.
+ */
+ public function merge(HeadData $data): self
+ {
+ $merged = clone $this;
+
+ foreach ($data->builders as $builder) {
+ $merged->overlayBuilder($builder);
+ }
+
+ return $merged;
+ }
+
+ /**
+ * Determine whether the head data has no tags.
+ */
+ public function isEmpty(): bool
+ {
+ foreach ($this->builders as $builder) {
+ if (! $builder->isEmpty()) {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ /**
+ * Mark all builders as belonging to the defaults layer.
+ */
+ public function asDefaults(): static
+ {
+ $defaults = clone $this;
+
+ foreach ($defaults->builders as $class => $builder) {
+ $defaults->builders[$class] = $builder->asDefaults();
+ }
+
+ return $defaults;
+ }
+
+ /**
+ * Copy the data and its builders for independent coroutine state.
+ */
+ public function replicate(): static
+ {
+ $replica = clone $this;
+
+ foreach ($this->builders as $class => $builder) {
+ $replica->builders[$class] = clone $builder;
+ }
+
+ return $replica;
+ }
+}
diff --git a/src/head/src/HeadManager.php b/src/head/src/HeadManager.php
new file mode 100644
index 0000000000..968737e450
--- /dev/null
+++ b/src/head/src/HeadManager.php
@@ -0,0 +1,375 @@
+>
+ */
+class HeadManager implements Arrayable, Htmlable
+{
+ use BuildsHead;
+ use Conditionable;
+ use Macroable;
+
+ public const string INERTIA_PROP = 'head';
+
+ protected HeadData $inertiaGlobals;
+
+ protected HeadData $defaults;
+
+ protected string $inertiaProp = self::INERTIA_PROP;
+
+ protected ErrorPages $errorPages;
+
+ /** @var null|(Closure(HeadBuilder): mixed) */
+ protected ?Closure $defaultsResolver = null;
+
+ /** @var null|(Closure(HeadBuilder, int): mixed) */
+ protected ?Closure $errorsResolver = null;
+
+ /**
+ * Create the head manager.
+ */
+ public function __construct(
+ protected Container $app,
+ protected HeadRenderer $renderer,
+ protected TagRegistry $registry,
+ ) {
+ $this->inertiaGlobals = new HeadData;
+ $this->defaults = new HeadData;
+ $this->errorPages = new ErrorPages(
+ $this->registry,
+ fn (callable $callback): HeadData => $this->define($callback),
+ );
+ }
+
+ /**
+ * Define head tags rendered as server-owned tags on every Inertia document.
+ *
+ * Boot-only. These tags are shared by every request in the worker.
+ *
+ * @param callable(HeadBuilder): mixed $callback
+ */
+ public function inertiaGlobals(callable $callback): static
+ {
+ $this->inertiaGlobals = $this->inertiaGlobals->merge($this->define($callback));
+
+ return $this;
+ }
+
+ /**
+ * Define default head data merged beneath every page's head.
+ *
+ * Boot-only. These defaults are shared by every request in the worker.
+ *
+ * @param callable(HeadBuilder): mixed $callback
+ */
+ public function defaults(callable $callback): static
+ {
+ $this->defaults = $this->defaults->merge($this->define($callback)->asDefaults());
+
+ return $this;
+ }
+
+ /**
+ * Resolve default head data from the current request context.
+ *
+ * Boot-only. The callback is shared by every request in the worker.
+ *
+ * @param null|(Closure(HeadBuilder): mixed) $resolver
+ */
+ public function resolveDefaultsUsing(?Closure $resolver): static
+ {
+ $this->defaultsResolver = $resolver;
+
+ return $this;
+ }
+
+ /**
+ * Define head data for error pages.
+ *
+ * Boot-only. These error definitions are shared by every request in the worker.
+ *
+ * @param callable(ErrorPages): mixed $callback
+ */
+ public function errors(callable $callback): static
+ {
+ $callback($this->errorPages);
+
+ return $this;
+ }
+
+ /**
+ * Resolve error head data from the current request context and status.
+ *
+ * Boot-only. The callback is shared by every request in the worker.
+ *
+ * @param null|(Closure(HeadBuilder, int): mixed) $resolver
+ */
+ public function resolveErrorsUsing(?Closure $resolver): static
+ {
+ $this->errorsResolver = $resolver;
+
+ return $this;
+ }
+
+ /**
+ * Resolve the canonical origin from the current request context.
+ *
+ * Boot-only. The callback persists in a static property and affects every request in the worker.
+ *
+ * @param null|(Closure(): ?string) $resolver
+ */
+ public function resolveCanonicalOriginUsing(?Closure $resolver): static
+ {
+ Canonical::resolveOriginUsing($resolver);
+
+ return $this;
+ }
+
+ /**
+ * Customize the Inertia prop the head elements are shared under.
+ *
+ * Boot-only. The prop name is shared by every request in the worker.
+ */
+ public function inertia(string $prop = self::INERTIA_PROP): static
+ {
+ $this->inertiaProp = $prop;
+
+ return $this;
+ }
+
+ /**
+ * Get the shared Inertia prop name.
+ */
+ public function inertiaProp(): string
+ {
+ return $this->inertiaProp;
+ }
+
+ /**
+ * Register a custom tag builder to render on every head.
+ *
+ * Boot-only. Registered builders affect every request in the worker.
+ *
+ * @param class-string $builder
+ */
+ public function extend(string $builder): static
+ {
+ $this->registry->extend($builder);
+
+ return $this;
+ }
+
+ /**
+ * Set the response status used to resolve error page head data.
+ */
+ public function status(int $status): static
+ {
+ $this->state()->setStatus($status);
+
+ return $this;
+ }
+
+ /**
+ * Resolve the document metadata as an array.
+ *
+ * @return array
+ */
+ public function toArray(?int $status = null): array
+ {
+ return $this->renderer->toArray($this->resolveDocument($status), $this->request());
+ }
+
+ /**
+ * Render the document head elements.
+ *
+ * @return array
+ */
+ public function toElements(?int $status = null): array
+ {
+ return $this->renderer->toElements($this->resolveDocument($status), $this->request());
+ }
+
+ /**
+ * Render the page-managed Inertia head elements.
+ *
+ * @return array
+ */
+ public function toInertiaElements(?int $status = null): array
+ {
+ return $this->renderer->toInertiaElements($this->resolve($status), $this->request());
+ }
+
+ /**
+ * Render the document head as HTML.
+ */
+ public function render(?int $status = null): HtmlString
+ {
+ return new HtmlString($this->renderer->render($this->resolveDocument($status), $this->request()));
+ }
+
+ /**
+ * Render tags for a Blade view, adding Inertia ownership attributes when
+ * the current request is rendering an Inertia page.
+ */
+ public function renderForView(?int $status = null): HtmlString
+ {
+ if (! $this->renderingInertiaPage()) {
+ return $this->render($status);
+ }
+
+ return new HtmlString($this->renderer->renderInertiaDocument(
+ $this->inertiaGlobals,
+ $this->resolve($status),
+ $this->request(),
+ ));
+ }
+
+ /**
+ * Get the rendered document head HTML.
+ */
+ public function toHtml(?int $status = null): string
+ {
+ return $this->render($status)->toHtml();
+ }
+
+ /**
+ * Flush runtime data for the current request scope.
+ */
+ public function flush(): static
+ {
+ $this->state()->flush();
+
+ return $this;
+ }
+
+ /**
+ * Run a definition callback against a fresh head builder and return its data.
+ */
+ protected function define(callable $callback): HeadData
+ {
+ $data = new HeadData;
+
+ $callback(new HeadBuilder($data, $this->schemaFactory()));
+
+ return $data;
+ }
+
+ /**
+ * Resolve all configured head layers into the final data for rendering.
+ */
+ protected function resolve(?int $status = null): HeadData
+ {
+ $data = (new HeadData)->merge($this->defaults);
+
+ if ($this->defaultsResolver !== null) {
+ $data = $data->merge($this->define($this->defaultsResolver)->asDefaults());
+ }
+
+ if ($route = $this->route()) {
+ foreach (RouteAttributeParser::routeMetadata($route) as $attributes) {
+ $data = RouteAttributeParser::apply($data, $attributes, $this->registry);
+ }
+ }
+
+ $data = $data->merge($this->state()->data());
+
+ $errorStatus = $status ?? $this->state()->status();
+
+ if (! is_null($errorStatus) && $error = $this->errorPages->forStatus($errorStatus)) {
+ $data = $data->merge($error);
+ }
+
+ if ($errorStatus !== null && $this->errorsResolver !== null) {
+ $data = $data->merge($this->define(
+ fn (HeadBuilder $head): mixed => ($this->errorsResolver)($head, $errorStatus),
+ ));
+ }
+
+ return $data;
+ }
+
+ /**
+ * Resolve server-owned globals beneath the page metadata.
+ */
+ protected function resolveDocument(?int $status = null): HeadData
+ {
+ return (new HeadData)
+ ->merge($this->inertiaGlobals)
+ ->merge($this->resolve($status));
+ }
+
+ /**
+ * Determine if the current request is rendering an Inertia page. Inertia
+ * stores the page in coroutine-local state before the root view renders, so
+ * this holds at any depth of the view tree, unlike the view's variables.
+ */
+ protected function renderingInertiaPage(): bool
+ {
+ return class_exists(InertiaState::class)
+ && (CoroutineContext::get(InertiaState::CONTEXT_KEY)->page ?? []) !== [];
+ }
+
+ /**
+ * Get the head data instance the fluent methods should write to.
+ */
+ protected function headData(): HeadData
+ {
+ return $this->state()->data();
+ }
+
+ /**
+ * Get the registered schema factory.
+ */
+ protected function schemaFactory(): SchemaFactory
+ {
+ return $this->app->make(SchemaFactory::class);
+ }
+
+ /**
+ * Get the current coroutine's head state.
+ */
+ protected function state(): CurrentHead
+ {
+ return $this->app->make(CurrentHead::class);
+ }
+
+ /**
+ * Get the current request or the application's console request.
+ */
+ protected function request(): ?Request
+ {
+ return $this->app->make('request');
+ }
+
+ /**
+ * Get the current request's route.
+ */
+ protected function route(): ?Route
+ {
+ $route = $this->request()?->route();
+
+ return $route instanceof Route ? $route : null;
+ }
+}
diff --git a/src/head/src/HeadServiceProvider.php b/src/head/src/HeadServiceProvider.php
new file mode 100644
index 0000000000..12561645ac
--- /dev/null
+++ b/src/head/src/HeadServiceProvider.php
@@ -0,0 +1,81 @@
+app->scoped(CurrentHead::class);
+
+ $this->app->singleton('head.schema', fn (): SchemaFactory => new SchemaFactory);
+ $this->app->alias('head.schema', SchemaFactory::class);
+
+ $this->app->singleton('head', fn (Container $app): HeadManager => new HeadManager(
+ $app,
+ $app->make(HeadRenderer::class),
+ $app->make(TagRegistry::class),
+ ));
+ $this->app->alias('head', HeadManager::class);
+ }
+
+ /**
+ * Bootstrap the package services.
+ */
+ public function boot(): void
+ {
+ Blade::directive(
+ 'head',
+ fn (string $expression): string => $expression === ''
+ ? "renderForView(); ?>"
+ : "renderForView({$expression}); ?>"
+ );
+
+ (new HeadRouteMacros)->register();
+
+ $this->registerExceptionStatusResolver();
+
+ // All providers must be registered before checking whether Inertia is loaded.
+ (new InertiaIntegration($this->app))->register();
+ }
+
+ /**
+ * Capture error status before Blade views and final response customization.
+ */
+ protected function registerExceptionStatusResolver(): void
+ {
+ $this->callAfterResolving(ExceptionHandler::class, function (object $handler): void {
+ $head = $this->app->make(HeadManager::class);
+
+ /** @var Handler $handler */
+ $handler->renderable(function (Throwable $exception, Request $request) use ($head): void {
+ $head->status(
+ $exception instanceof HttpExceptionInterface ? $exception->getStatusCode() : 500
+ );
+ });
+
+ $handler->beforeResponding(function (Response $response) use ($head): void {
+ $head->status($response->getStatusCode());
+ });
+ });
+ }
+}
diff --git a/src/head/src/Inertia/Integration.php b/src/head/src/Inertia/Integration.php
new file mode 100644
index 0000000000..53c12e73e7
--- /dev/null
+++ b/src/head/src/Inertia/Integration.php
@@ -0,0 +1,67 @@
+app->providerIsLoaded(InertiaServiceProvider::class)) {
+ return;
+ }
+
+ $this->decorateSsrGateway();
+ $this->registerMiddleware();
+ }
+
+ /**
+ * Share head elements during every request in coroutine-local Inertia state.
+ */
+ protected function registerMiddleware(): void
+ {
+ $push = static function (Kernel $kernel): void {
+ $kernel->pushMiddleware(ShareHead::class);
+ };
+
+ $this->app->afterResolving(Kernel::class, $push);
+
+ if ($this->app->resolved(Kernel::class)) {
+ $push($this->app->make(Kernel::class));
+ }
+ }
+
+ /**
+ * Strip page-managed head elements from Inertia SSR responses at the
+ * gateway, so @head remains authoritative for the initial document no
+ * matter where it renders relative to Inertia's head component.
+ */
+ protected function decorateSsrGateway(): void
+ {
+ $this->app->extend(Gateway::class, fn (Gateway $gateway): Gateway => new UniqueHeadGateway(
+ $gateway,
+ $this->app->make(HeadManager::class),
+ $this->app->make(HeadRenderer::class),
+ ));
+ }
+}
diff --git a/src/head/src/Inertia/ShareHead.php b/src/head/src/Inertia/ShareHead.php
new file mode 100644
index 0000000000..df3df9bda7
--- /dev/null
+++ b/src/head/src/Inertia/ShareHead.php
@@ -0,0 +1,39 @@
+head->inertiaProp(),
+ fn (): array => $this->head->toInertiaElements(),
+ );
+
+ return $next($request);
+ }
+}
diff --git a/src/head/src/Inertia/UniqueHeadGateway.php b/src/head/src/Inertia/UniqueHeadGateway.php
new file mode 100644
index 0000000000..34cffa7bb9
--- /dev/null
+++ b/src/head/src/Inertia/UniqueHeadGateway.php
@@ -0,0 +1,104 @@
+ $page
+ */
+ public function dispatch(array $page): ?Response
+ {
+ $response = $this->gateway->dispatch($page);
+
+ if (is_null($response)) {
+ return null;
+ }
+
+ /** @var array $elements */
+ $elements = Arr::get($page, 'props.' . $this->head->inertiaProp(), []);
+
+ return new Response(
+ $this->renderer->withoutInertiaElements($response->head, $elements),
+ $response->body,
+ );
+ }
+
+ /**
+ * Forward conditional SSR disabling to the decorated gateway.
+ */
+ public function disable(Closure|bool $condition): void
+ {
+ if (! $this->gateway instanceof DisablesSsr) {
+ throw new LogicException('The configured SSR gateway does not support disabling server-side rendering conditionally.');
+ }
+
+ $this->gateway->disable($condition);
+ }
+
+ /**
+ * Forward SSR path exclusions to the decorated gateway.
+ *
+ * @param array|string $paths
+ */
+ public function except(array|string $paths): void
+ {
+ if (! $this->gateway instanceof ExcludesSsrPaths) {
+ throw new LogicException('The configured SSR gateway does not support excluding paths from server-side rendering.');
+ }
+
+ $this->gateway->except($paths);
+ }
+
+ /**
+ * Forward SSR request configuration to the decorated gateway.
+ */
+ public function configureRequestUsing(?Closure $callback = null): void
+ {
+ if (! $this->gateway instanceof ConfiguresSsrRequests) {
+ throw new LogicException('The configured SSR gateway does not support configuring server-side rendering requests.');
+ }
+
+ $this->gateway->configureRequestUsing($callback);
+ }
+
+ /**
+ * Forward SSR health checks to the decorated gateway.
+ */
+ public function isHealthy(): bool
+ {
+ if (! $this->gateway instanceof HasHealthCheck) {
+ throw new LogicException('The configured SSR gateway does not support health checks.');
+ }
+
+ return $this->gateway->isHealthy();
+ }
+}
diff --git a/src/head/src/PHPStan/RoutingMacros.php b/src/head/src/PHPStan/RoutingMacros.php
new file mode 100644
index 0000000000..d5cd549269
--- /dev/null
+++ b/src/head/src/PHPStan/RoutingMacros.php
@@ -0,0 +1,80 @@
+|string $title
+ * @param null|array|string|true $canonical
+ * @param null|array|RobotsRule|string $robots
+ * @param null|array $og
+ * @param null|array|string $ogImage
+ * @param null|array|string $ogVideo
+ * @param null|array|string $ogAudio
+ * @param null|array $twitter
+ * @param null|array|string $twitterImage
+ * @param null|array|string $preload
+ * @param null|array|string $prefetch
+ * @param null|array|string $preconnect
+ * @param null|array|string $dnsPrefetch
+ * @param null|array $alternates
+ * @param null|array|string $feed
+ * @param null|array|string $icon
+ * @param null|array|string $favicon
+ * @param null|array|string $appleTouchIcon
+ * @param null|array|string $appleTouchStartupImage
+ * @param null|array|string $maskIcon
+ * @param null|array|string $manifest
+ * @param null|array $schema
+ * @param null|array $meta
+ * @param null|array $link
+ * @param array $extensions
+ */
+ public function withHead(
+ string|array|null $title = null,
+ ?string $description = null,
+ string|array|true|null $canonical = null,
+ string|RobotsRule|array|null $robots = null,
+ ?string $themeColor = null,
+ ?string $applicationName = null,
+ ?string $colorScheme = null,
+ ?string $referrer = null,
+ ?string $viewport = null,
+ ?string $appleWebAppTitle = null,
+ ?bool $webAppCapable = null,
+ ?string $appleWebAppStatusBarStyle = null,
+ ?array $og = null,
+ string|array|null $ogImage = null,
+ string|array|null $ogVideo = null,
+ string|array|null $ogAudio = null,
+ ?array $twitter = null,
+ string|array|null $twitterImage = null,
+ string|array|null $preload = null,
+ string|array|null $prefetch = null,
+ string|array|null $preconnect = null,
+ string|array|null $dnsPrefetch = null,
+ ?array $alternates = null,
+ string|array|null $feed = null,
+ string|array|null $icon = null,
+ string|array|null $favicon = null,
+ string|array|null $appleTouchIcon = null,
+ string|array|null $appleTouchStartupImage = null,
+ string|array|null $maskIcon = null,
+ string|array|null $manifest = null,
+ ?array $schema = null,
+ ?array $meta = null,
+ ?array $link = null,
+ array $extensions = [],
+ ): object;
+}
diff --git a/src/head/src/PHPStan/WithHeadExtension.php b/src/head/src/PHPStan/WithHeadExtension.php
new file mode 100644
index 0000000000..bc4edc3e99
--- /dev/null
+++ b/src/head/src/PHPStan/WithHeadExtension.php
@@ -0,0 +1,65 @@
+hasNativeMethod($methodName)) {
+ return false;
+ }
+
+ foreach ([Router::class, RouteRegistrar::class, Route::class, PendingResourceRegistration::class, PendingSingletonResourceRegistration::class, RouteFacade::class] as $class) {
+ if ($classReflection->getAncestorWithClassName($class) !== null) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ /**
+ * Reflect the macro signature with the receiving routing object's return type.
+ */
+ public function getMethod(ClassReflection $classReflection, string $methodName): MethodReflection
+ {
+ $method = $this->reflectionProvider->getClass(RoutingMacros::class)->getNativeMethod('withHead');
+ $isStatic = $classReflection->getAncestorWithClassName(RouteFacade::class) !== null;
+ $returnType = $isStatic || $classReflection->getAncestorWithClassName(Router::class) !== null
+ ? new ObjectType(RouteRegistrar::class)
+ : new ThisType($classReflection);
+
+ return new WithHeadMethodReflection(
+ $classReflection,
+ $method,
+ $returnType,
+ $isStatic,
+ );
+ }
+}
diff --git a/src/head/src/PHPStan/WithHeadMethodReflection.php b/src/head/src/PHPStan/WithHeadMethodReflection.php
new file mode 100644
index 0000000000..85d3792b44
--- /dev/null
+++ b/src/head/src/PHPStan/WithHeadMethodReflection.php
@@ -0,0 +1,155 @@
+signature->getName();
+ }
+
+ /**
+ * Return the declaring class.
+ */
+ public function getDeclaringClass(): ClassReflection
+ {
+ return $this->declaringClass;
+ }
+
+ /**
+ * Determine whether the method is static.
+ */
+ public function isStatic(): bool
+ {
+ return $this->isStatic;
+ }
+
+ /**
+ * Determine whether the method is private.
+ */
+ public function isPrivate(): bool
+ {
+ return $this->signature->isPrivate();
+ }
+
+ /**
+ * Determine whether the method is public.
+ */
+ public function isPublic(): bool
+ {
+ return $this->signature->isPublic();
+ }
+
+ /**
+ * Return the method docblock.
+ */
+ public function getDocComment(): ?string
+ {
+ return $this->signature->getDocComment();
+ }
+
+ /**
+ * Return the method prototype.
+ */
+ public function getPrototype(): ClassMemberReflection
+ {
+ return $this;
+ }
+
+ /**
+ * Return the callable variants with the routing result type.
+ *
+ * @return list
+ */
+ public function getVariants(): array
+ {
+ return array_map(
+ fn (ParametersAcceptor $variant): FunctionVariant => new FunctionVariant(
+ $variant->getTemplateTypeMap(),
+ $variant->getResolvedTemplateTypeMap(),
+ $variant->getParameters(),
+ $variant->isVariadic(),
+ $this->returnType,
+ $variant instanceof ExtendedParametersAcceptor ? $variant->getCallSiteVarianceMap() : null,
+ ),
+ $this->signature->getVariants(),
+ );
+ }
+
+ /**
+ * Determine whether the method is deprecated.
+ */
+ public function isDeprecated(): TrinaryLogic
+ {
+ return $this->signature->isDeprecated();
+ }
+
+ /**
+ * Return the deprecation description.
+ */
+ public function getDeprecatedDescription(): ?string
+ {
+ return $this->signature->getDeprecatedDescription();
+ }
+
+ /**
+ * Determine whether the method is final.
+ */
+ public function isFinal(): TrinaryLogic
+ {
+ return $this->signature->isFinal();
+ }
+
+ /**
+ * Determine whether the method is internal.
+ */
+ public function isInternal(): TrinaryLogic
+ {
+ return $this->signature->isInternal();
+ }
+
+ /**
+ * Return the declared throw type.
+ */
+ public function getThrowType(): ?Type
+ {
+ return $this->signature->getThrowType();
+ }
+
+ /**
+ * Determine whether the method has side effects.
+ */
+ public function hasSideEffects(): TrinaryLogic
+ {
+ return $this->signature->hasSideEffects();
+ }
+}
diff --git a/src/head/src/Rendering/HeadRenderer.php b/src/head/src/Rendering/HeadRenderer.php
new file mode 100644
index 0000000000..e1801fcb25
--- /dev/null
+++ b/src/head/src/Rendering/HeadRenderer.php
@@ -0,0 +1,164 @@
+tags = $tags ?? new TagRenderer;
+ }
+
+ /**
+ * Render the resolved head as an HTML string.
+ */
+ public function render(HeadData $head, ?Request $request = null): string
+ {
+ return implode(PHP_EOL, $this->toElements($head, $request));
+ }
+
+ /**
+ * Render the initial Inertia document head as an HTML string.
+ */
+ public function renderInertiaDocument(HeadData $inertiaGlobals, HeadData $head, ?Request $request = null): string
+ {
+ return implode(PHP_EOL, $this->toInertiaDocumentElements($inertiaGlobals, $head, $request));
+ }
+
+ /**
+ * Render the resolved head as individual HTML element strings.
+ *
+ * @return array
+ */
+ public function toElements(HeadData $head, ?Request $request = null): array
+ {
+ return $this->elements($head, $request, $this->tags);
+ }
+
+ /**
+ * Render the resolved head as individual HTML element strings with stable
+ * data-inertia keys for Inertia's server-managed head reconciler.
+ *
+ * @return array
+ */
+ public function toInertiaElements(HeadData $head, ?Request $request = null): array
+ {
+ return $this->uniqueInertiaElements(
+ $this->elements($head, $request, $this->tags->withInertiaAttributes()),
+ );
+ }
+
+ /**
+ * Remove exact Inertia elements from an HTML string.
+ *
+ * @param array $elements
+ */
+ public function withoutInertiaElements(string $html, array $elements): string
+ {
+ return str_replace($elements, '', $html);
+ }
+
+ /**
+ * Render the initial Inertia document head. Inertia globals are emitted as
+ * plain, server-owned tags while page tags receive Inertia ownership keys.
+ *
+ * @return array
+ */
+ public function toInertiaDocumentElements(HeadData $inertiaGlobals, HeadData $head, ?Request $request = null): array
+ {
+ return [
+ ...$this->toElements($inertiaGlobals, $request),
+ ...$this->toInertiaElements($head, $request),
+ ];
+ }
+
+ /**
+ * Apply Inertia's last-write-wins ownership semantics to rendered elements.
+ *
+ * @param array $elements
+ * @return array
+ */
+ protected function uniqueInertiaElements(array $elements): array
+ {
+ $unique = [];
+ $indexes = [];
+
+ foreach ($elements as $element) {
+ $key = Str::match('/\sdata-inertia="([^"]+)"/', $element);
+
+ // Inertia collects titles separately from all attribute-keyed elements.
+ $key = str_starts_with($element, '
+ */
+ protected function elements(HeadData $head, ?Request $request, TagRenderer $tags): array
+ {
+ $resolved = new ResolvedHead($head, $this->registry, $request, $this->schemas);
+
+ return collect($resolved->builders())
+ ->flatMap(fn (TagBuilder $builder): array => $builder->toTags($resolved, $tags))
+ ->filter()
+ ->values()
+ ->all();
+ }
+
+ /**
+ * Render the resolved head as the array returned by Head::toArray().
+ *
+ * @return array
+ */
+ public function toArray(HeadData $head, ?Request $request = null): array
+ {
+ $resolved = new ResolvedHead($head, $this->registry, $request, $this->schemas);
+
+ $headArray = [];
+
+ foreach ($this->registry->builders() as $builder) {
+ Arr::set($headArray, $builder::headArrayKey(), $resolved->headArray($builder));
+ }
+
+ return $headArray;
+ }
+}
diff --git a/src/head/src/Rendering/ResolvedHead.php b/src/head/src/Rendering/ResolvedHead.php
new file mode 100644
index 0000000000..f2d4e0ba6b
--- /dev/null
+++ b/src/head/src/Rendering/ResolvedHead.php
@@ -0,0 +1,169 @@
+data;
+ }
+
+ /**
+ * Get the request used for rendering.
+ */
+ public function request(): ?Request
+ {
+ return $this->request;
+ }
+
+ /**
+ * Validate a structured data definition.
+ *
+ * @param array $schema
+ */
+ public function validateSchema(array $schema): void
+ {
+ $this->schemas?->validate($schema);
+ }
+
+ /**
+ * Get the resolved title.
+ */
+ public function title(): ?string
+ {
+ $title = $this->builder(Title::class);
+
+ return $title instanceof Title ? $title->render() : null;
+ }
+
+ /**
+ * Get the resolved description.
+ */
+ public function description(): ?string
+ {
+ $description = $this->builder(Description::class);
+
+ return $description instanceof Description ? $description->render() : null;
+ }
+
+ /**
+ * Get the resolved canonical URL.
+ */
+ public function canonical(): ?string
+ {
+ $canonical = $this->builder(Canonical::class);
+
+ return $canonical instanceof Canonical ? $canonical->render($this->request) : null;
+ }
+
+ /**
+ * Get the resolved robots directives.
+ */
+ public function robots(): ?string
+ {
+ $robots = $this->builder(Robots::class);
+
+ return $robots instanceof Robots ? $robots->render() : null;
+ }
+
+ /**
+ * Get the first Open Graph image and its alternative text.
+ *
+ * @return null|array
+ */
+ public function openGraphImage(): ?array
+ {
+ $openGraph = $this->builder(OpenGraph::class);
+
+ if (! $openGraph instanceof OpenGraph || ($images = $openGraph->images()) === []) {
+ return null;
+ }
+
+ $image = reset($images);
+
+ return Arr::whereNotNull([
+ 'url' => $image['url'],
+ 'alt' => $image['alt'] ?? null,
+ ]);
+ }
+
+ /**
+ * Get the tag builders to render, in declared order, including those that derive
+ * their values from the rest of the head even when never set explicitly.
+ *
+ * @return array
+ */
+ public function builders(): array
+ {
+ $builders = [];
+
+ foreach ($this->registry->builders() as $builder) {
+ $value = $this->builder($builder);
+
+ if ($value instanceof TagBuilder) {
+ $builders[] = $value;
+ } elseif ($builder::rendersWhenEmpty($this)) {
+ $builders[] = new $builder;
+ }
+ }
+
+ return $builders;
+ }
+
+ /**
+ * Resolve one tag builder into its Head::toArray() value,
+ * including defaults for builder data that was never set.
+ *
+ * @param class-string $builder
+ */
+ public function headArray(string $builder): mixed
+ {
+ $value = $this->builder($builder);
+
+ if (! $value instanceof TagBuilder && $builder::rendersWhenEmpty($this)) {
+ $value = new $builder;
+ }
+
+ return $value?->toHeadArray($this) ?? $builder::headArrayDefault();
+ }
+
+ /**
+ * Get an existing tag builder from the resolved data.
+ *
+ * @param class-string $builder
+ */
+ public function builder(string $builder): ?TagBuilder
+ {
+ return $this->data->get($builder);
+ }
+}
diff --git a/src/head/src/Rendering/TagRenderer.php b/src/head/src/Rendering/TagRenderer.php
new file mode 100644
index 0000000000..d2a1fe1617
--- /dev/null
+++ b/src/head/src/Rendering/TagRenderer.php
@@ -0,0 +1,221 @@
+element('title', e($title), $this->inertiaAttributes($inertiaKey));
+ }
+
+ /**
+ * Render a meta tag keyed by the given attribute ("name" or "property").
+ */
+ public function meta(string $attribute, string $key, string|int|float|bool $content, ?string $inertiaKey = null): string
+ {
+ $inertiaKey ??= $key;
+
+ return $this->metaWithAttributes($attribute, $key, ['content' => (string) $content], $inertiaKey);
+ }
+
+ /**
+ * Render a meta tag keyed by the given attribute ("name" or "property") with arbitrary attributes.
+ *
+ * @param array $attributes
+ */
+ public function metaWithAttributes(string $attribute, string $key, array $attributes, ?string $inertiaKey = null): string
+ {
+ $inertiaKey ??= $key;
+
+ return $this->voidElement(
+ 'meta',
+ $this->inertiaAttributes($inertiaKey),
+ [$attribute => $key],
+ $attributes,
+ );
+ }
+
+ /**
+ * Render a link tag.
+ */
+ public function link(string $rel, string $href, ?string $inertiaKey = null): string
+ {
+ $inertiaKey ??= $this->stableKey($rel, $href);
+
+ return $this->voidElement(
+ 'link',
+ $this->inertiaAttributes($inertiaKey),
+ ['rel' => $rel, 'href' => $href],
+ );
+ }
+
+ /**
+ * Render a link tag with arbitrary attributes.
+ *
+ * @param array $attributes
+ */
+ public function linkWithAttributes(string $rel, array $attributes, ?string $inertiaKey = null): string
+ {
+ $inertiaKey ??= $this->stableKey($rel, (string) ($attributes['href'] ?? serialize($attributes)));
+
+ return $this->voidElement(
+ 'link',
+ $this->inertiaAttributes($inertiaKey),
+ ['rel' => $rel],
+ $attributes,
+ );
+ }
+
+ /**
+ * Render structured data with script-safe JSON encoding.
+ *
+ * @param array $schema
+ */
+ public function jsonLd(array $schema, ?string $inertiaKey = null): string
+ {
+ $json = json_encode($schema, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_THROW_ON_ERROR);
+ $inertiaKey ??= $this->stableKey('schema', $json);
+
+ return $this->element(
+ 'script',
+ $json,
+ $this->inertiaAttributes($inertiaKey),
+ ['type' => 'application/ld+json'],
+ );
+ }
+
+ /**
+ * Render escaped HTML attributes.
+ *
+ * @param array $attributes
+ */
+ public function attributes(array $attributes): string
+ {
+ return $this->renderAttributes($attributes);
+ }
+
+ /**
+ * Attribute names aren't HTML-escaped, so reject anything that could
+ * break out of the attribute position. The "u" modifier also makes
+ * invalid UTF-8 fail the match.
+ */
+ protected function isValidAttributeName(string $name): bool
+ {
+ return preg_match('/^[^\x00-\x20\x7F-\x9F"\'\/=>]+$/u', $name) === 1;
+ }
+
+ /**
+ * Generate a stable, content-derived data-inertia key for a tag.
+ */
+ public function stableKey(string $prefix, string $value): string
+ {
+ return $prefix . ':' . substr(hash('xxh128', $value), 0, 16);
+ }
+
+ /**
+ * Validate and escape the combined attribute sets.
+ *
+ * @param array ...$attributeSets
+ */
+ protected function renderAttributes(array ...$attributeSets): string
+ {
+ $rendered = [];
+ $seen = [];
+
+ foreach ($attributeSets as $attributes) {
+ foreach ($attributes as $name => $value) {
+ if ($value === false || is_null($value)) {
+ continue;
+ }
+
+ $name = (string) $name;
+
+ if (! $this->isValidAttributeName($name)) {
+ throw new InvalidArgumentException('Invalid HTML attribute name.');
+ }
+
+ $normalizedName = strtolower($name);
+
+ if (isset($seen[$normalizedName])) {
+ throw new InvalidArgumentException("Duplicate HTML attribute name [{$name}].");
+ }
+
+ $seen[$normalizedName] = true;
+ $rendered[] = $value === true
+ ? $name
+ : $name . '="' . e((string) $value) . '"';
+ }
+ }
+
+ return implode(' ', $rendered);
+ }
+
+ /**
+ * Render a void HTML element.
+ *
+ * @param array ...$attributeSets
+ */
+ protected function voidElement(string $name, array ...$attributeSets): string
+ {
+ return '<' . $name . $this->renderedAttributes(...$attributeSets) . '>';
+ }
+
+ /**
+ * Render an HTML element with content.
+ *
+ * @param array ...$attributeSets
+ */
+ protected function element(string $name, string $content, array ...$attributeSets): string
+ {
+ return '<' . $name . $this->renderedAttributes(...$attributeSets) . '>' . $content . '' . $name . '>';
+ }
+
+ /**
+ * Render attributes with their leading separator.
+ *
+ * @param array ...$attributeSets
+ */
+ protected function renderedAttributes(array ...$attributeSets): string
+ {
+ $attributes = $this->renderAttributes(...$attributeSets);
+
+ return $attributes === '' ? '' : ' ' . $attributes;
+ }
+
+ /**
+ * Get the ownership attribute when Inertia rendering is enabled.
+ *
+ * @return array
+ */
+ protected function inertiaAttributes(?string $key): array
+ {
+ return $this->withInertiaAttributes && ! is_null($key)
+ ? ['data-inertia' => $key]
+ : [];
+ }
+}
diff --git a/src/head/src/Routing/HeadRouteMacros.php b/src/head/src/Routing/HeadRouteMacros.php
new file mode 100644
index 0000000000..33585afdbd
--- /dev/null
+++ b/src/head/src/Routing/HeadRouteMacros.php
@@ -0,0 +1,462 @@
+routerMacro());
+ RouteRegistrar::macro('withHead', $this->registrarMacro());
+ Route::macro('withHead', $this->routeMacro());
+ PendingResourceRegistration::macro('withHead', $this->pendingResourceMacro());
+ PendingSingletonResourceRegistration::macro('withHead', $this->pendingSingletonMacro());
+ }
+
+ /**
+ * Recover the typed routing object a macro closure was bound to.
+ *
+ * Macroable rebinds each macro closure at call time, so inside the
+ * closure $this is the routing object rather than this registrar.
+ *
+ * @template TScope of object
+ *
+ * @param class-string $scope
+ * @return TScope
+ */
+ public static function bound(mixed $context, string $scope): object
+ {
+ /** @var TScope $context */
+ return $context;
+ }
+
+ /**
+ * Create the withHead() macro for group-position calls on the router,
+ * such as Route::withHead(...)->prefix('admin')->group(...).
+ */
+ protected function routerMacro(): Closure
+ {
+ /**
+ * @param null|array|string $title
+ * @param null|array|string|true $canonical
+ * @param null|array|RobotsRule|string $robots
+ * @param null|array $og
+ * @param null|array|string $ogImage
+ * @param null|array|string $ogVideo
+ * @param null|array|string $ogAudio
+ * @param null|array $twitter
+ * @param null|array|string $twitterImage
+ * @param null|array|string $preload
+ * @param null|array|string $prefetch
+ * @param null|array|string $preconnect
+ * @param null|array|string $dnsPrefetch
+ * @param null|array $alternates
+ * @param null|array|string $feed
+ * @param null|array|string $icon
+ * @param null|array|string $favicon
+ * @param null|array|string $appleTouchIcon
+ * @param null|array|string $appleTouchStartupImage
+ * @param null|array|string $maskIcon
+ * @param null|array|string $manifest
+ * @param null|array $schema
+ * @param null|array $meta
+ * @param null|array $link
+ * @param array $extensions
+ */
+ $macro = function (
+ string|array|null $title = null,
+ ?string $description = null,
+ string|array|true|null $canonical = null,
+ string|RobotsRule|array|null $robots = null,
+ ?string $themeColor = null,
+ ?string $applicationName = null,
+ ?string $colorScheme = null,
+ ?string $referrer = null,
+ ?string $viewport = null,
+ ?string $appleWebAppTitle = null,
+ ?bool $webAppCapable = null,
+ ?string $appleWebAppStatusBarStyle = null,
+ ?array $og = null,
+ string|array|null $ogImage = null,
+ string|array|null $ogVideo = null,
+ string|array|null $ogAudio = null,
+ ?array $twitter = null,
+ string|array|null $twitterImage = null,
+ string|array|null $preload = null,
+ string|array|null $prefetch = null,
+ string|array|null $preconnect = null,
+ string|array|null $dnsPrefetch = null,
+ ?array $alternates = null,
+ string|array|null $feed = null,
+ string|array|null $icon = null,
+ string|array|null $favicon = null,
+ string|array|null $appleTouchIcon = null,
+ string|array|null $appleTouchStartupImage = null,
+ string|array|null $maskIcon = null,
+ string|array|null $manifest = null,
+ ?array $schema = null,
+ ?array $meta = null,
+ ?array $link = null,
+ array $extensions = [],
+ ): RouteRegistrar {
+ $arguments = get_defined_vars();
+
+ $router = HeadRouteMacros::bound($this, Router::class);
+
+ return (new RouteRegistrar($router))->metadata(
+ RouteAttributeParser::metadataFromArguments($arguments)
+ );
+ };
+
+ return $macro;
+ }
+
+ /**
+ * Create the withHead() macro for chained group-position calls, such as
+ * Route::prefix('admin')->withHead(...)->group(...).
+ */
+ protected function registrarMacro(): Closure
+ {
+ /**
+ * @param null|array|string $title
+ * @param null|array|string|true $canonical
+ * @param null|array|RobotsRule|string $robots
+ * @param null|array $og
+ * @param null|array|string $ogImage
+ * @param null|array