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/docs/plans/2026-10-21-head-port.md b/docs/plans/2026-10-21-head-port.md
new file mode 100644
index 0000000000..704ddb065d
--- /dev/null
+++ b/docs/plans/2026-10-21-head-port.md
@@ -0,0 +1,394 @@
+# Head package port
+
+## Status and scope
+
+The port, initial PR corrections, and upstream additions are implemented, validated, and reviewed in `/home/binaryfire/workspace/contrib/hypervel/components-head`, branch `feature/head`, based on `0.4`. Next: commit the additions, push to both remotes, reply to bot comments on both PRs, complete the remaining CI/review round, merge the main PR and clean up the worktree when safe.
+
+Port Laravel Head as `hypervel/head`, preserving its fluent metadata, route macros, Blade rendering, structured data, and optional Inertia integration. Support concurrent tenants, runtime tenant defaults and error pages, canonical tenant origins, and copied child coroutines. Keep the package independent of private tenancy code. Head does not query databases or own a persistent tenant cache; settings retrieval and caching remain application responsibilities.
+
+After every compaction, read this plan, monorepo root `CLAUDE.md`, components `AGENTS.md`, and relevant READMEs in full. Apply the source/test port workflow one file at a time. Flexibility is useful when it serves these requirements; avoid new registries, parsers, caching layers, and compatibility machinery without a concrete need.
+
+## References and verified boundaries
+
+Paths below are relative to `/home/binaryfire/workspace` unless stated otherwise:
+
+| Reference | Implementation relevance |
+| --- | --- |
+| `references/laravel/head/src`, `tests`, `_ide_helper.php`, `composer.json` | Authoritative upstream API, named parameters, protected extension methods, source order, and tests. |
+| `references/laravel/docs/head.md` | Full feature documentation, precedence, supported SSR setup, and client migration requirements. |
+| `references/inertia/packages/core/src/head.ts` and React `App.ts`, `Head.ts`, `createInertiaApp.ts` | `serverHead`, title handling, stable ownership keys, hydration, navigation, and SSR protocol. Relevant files in the checked-out 3.9.1 source match the locally installed 3.7.1 files. |
+| `references/laravel/inertia-laravel` | Adapter behavior and capability interfaces. |
+| Worktree `src/inertia/src/{InertiaState,Response,ResponseFactory,InertiaServiceProvider}.php`, `Ssr/`, `Commands/CheckSsr.php` | Coroutine-local page/SSR state, immutable SSR result, request configurator capability, and health diagnostics. |
+| Worktree `src/container/src/{Container,ContainerResolutionState}.php`, `src/context/src/{CoroutineContext,ReplicableContext,RequestContext}.php` | Unbound auto-singletons, scoped instances, and context replication. |
+| Worktree `src/routing/src/ResolvesRouteDependencies.php` | Normal controller dependency injection calls `make()`; mutable builders need intrinsic fresh lifetimes. |
+| Worktree `src/database/extension.neon`, `src/database/src/PHPStan/`, `types/Routing/Route.php` | Optional package analysis support and type-fixture conventions. |
+| Worktree `src/cache/{composer.json,LICENSE.md,README.md}`, `src/testing/src/PHPUnit/AfterEachTestSubscriber.php` | First-party package skeleton and optional static cleanup. |
+
+Root Composer/npm metadata is development-only. Installed applications use individual subtree splits; optional Inertia classes and provider discovery cannot be inferred from the components root manifest.
+
+## Compatibility decisions
+
+Keep existing Laravel public/protected method names, parameter names, ordering, and extension dispatch, except approved framework type adaptations. No deprecated Head API or legacy-version shim has been identified for removal. Preserve eager `defaults()`, `errors()`, and `inertiaGlobals()`; add runtime resolvers instead of changing their semantics.
+
+These correctness fixes deliberately change two narrow upstream extension contracts:
+
+1. **SSR health capability:** change `UniqueHeadGateway::isHealthy()` when the wrapped gateway lacks `HasHealthCheck` from `false` to `LogicException('The configured SSR gateway does not support health checks.')`. Upstream explicitly tests `false`. The wrapper advertises the interface, so otherwise `inertia:check-ssr` falsely reports an unavailable server. Keep supported gateway health results unchanged. No capability registry, alternate decorator classes, or broad exception catch in the command.
+2. **Open Graph protected state:** replace the single protected `$defaults` flag with independent media provenance flags. One flag cannot describe a page image together with inherited default video/audio. Public constructors and methods stay unchanged; subclasses directly accessing that protected property need adaptation. Preserve `overlayMedia()` and `hasMedia()` as useful extension methods rather than retaining dead compatibility state.
+
+Observable correctness fixes also belong in the owner summary: canonical query/fragment preservation, Open Graph media precedence, literal `"0"` values, manifest replacement, title/meta ownership separation, and direct builders honoring registered schema factories. These are bug fixes, not README differences. Generated content hashes change to `xxh128`, but remain deterministic; Inertia treats their values as opaque identifiers.
+
+Keep Hypervel's `Ssr\Response::$head` and `$body` readonly. Return a new response when stripping duplicate Head tags. This is an internal immutable rendering result, not a read-only application: forms, mutations, database writes, and normal Inertia requests remain available. No JavaScript package fork or wire-protocol change is needed.
+
+The owner also approved accepting `Paginator|CursorPaginator` in `paginate()` and `fromPaginator()`, and replacing pagination links as one complete relation set. Both paginator contracts expose the same previous/next URL methods. Replacement removes stale relations when the current page has no previous or next page; custom manual definitions must supply their complete set rather than relying on per-relation inheritance. Explain replacement briefly in code and document these behaviors in the Head guide; correctness fixes and additive support do not belong in the README differences or porting guide.
+
+## Package and provider wiring
+
+Create `src/head/` using the cache package's metadata/license conventions: PHP `^8.4`, Hypervel `^0.4`, `Hypervel\\Head\\` mapped to `src/`, provider and `Head` alias under `extra.hypervel`. Preserve upstream copyright. README: package name, canonical documentation link, any approved lasting public differences, upstream link. Do not duplicate the feature guide.
+
+Declare actual runtime dependencies from final imports: `hypervel/collections`, `conditionable`, `container`, `context`, `contracts`, `foundation`, `http`, `macroable`, `routing`, `support`, `view`; directly used PSR Log and Symfony HTTP Foundation/Kernel packages use the repository's compatible constraints. `Arr` belongs to collections even though its namespace is `Hypervel\Support`. Foundation is required for the compiled directive's `app()` call and performance links' `asset()` calls; it is not merely a PHPDoc dependency. Add any further real dependency revealed by final imports. `hypervel/inertia` is optional and may be suggested for the documented integration. No direct Livewire, Pest, database, or frontend dependency is required by Head itself. PHPStan is optional development tooling, not a runtime requirement.
+
+Root development `composer.json`: autoload Head, add `hypervel/head` to `replace`, provider and `Head` alias to discovery metadata. Do not add Head to `DefaultProviders`. Refresh autoload metadata through Composer; use package-manager commands for any actual root dependency addition. Check installed-package manifests independently.
+
+`HeadServiceProvider::register()`:
+
+```php
+$this->app->scoped(CurrentHead::class);
+$this->app->singleton('head.schema', fn () => new SchemaFactory);
+$this->app->alias('head.schema', SchemaFactory::class);
+$this->app->singleton('head', fn ($app) => new HeadManager(
+ $app,
+ $app->make(HeadRenderer::class),
+ $app->make(TagRegistry::class),
+));
+$this->app->alias('head', HeadManager::class);
+```
+
+The remaining worker-safe concrete services use auto-singletoning. Facades resolve the canonical string keys. Bind no duplicate concrete singleton aliases. In `boot()`, register Blade and route macros, install exception status capture with `callAfterResolving()` so an already-resolved handler is covered, then register optional Inertia integration after providers have been registered.
+
+Integration checks `getProviders(InertiaServiceProvider::class) !== []`, which recognizes registered subclasses as well as the base provider, not mere class availability. Preserve the middleware's early-kernel handling. `Integration` is not a service provider: keep its existing `afterResolving` plus `resolved()` immediate-call pattern, rather than calling the protected ServiceProvider helper from it. Type its callback argument as the Kernel contract and call `prependMiddleware()` directly. Register the lazy Head prop before global middleware can throw, so Inertia error responses also receive it. `Container::extend()` also decorates an already-resolved gateway. No container/provider availability work belongs on the request hot path.
+
+### Exception response status
+
+Keep Head's early `renderable()` callback for ordinary Blade error views, which render before the final response exists. Also capture the actual response status before application response customization: exception `render()` methods, `Responsable` exceptions and earlier render callbacks can bypass Head's early callback, while `HttpResponseException`, authentication and validation responses can have a different status from its inferred 500.
+
+Add `Foundation\Exceptions\Handler::beforeResponding(callable $callback): static`, with boot-only registration and a worker-held list of closures. Invoke them with `(Response, Throwable, Request)` after request-local `afterResponse()` callbacks and immediately before the single `respondUsing()` callback. Ignore their return values; response replacement remains with the existing APIs. Use a plain loop, without middleware, events or exception-type guesses. Add the matching `Configuration\Exceptions` delegate and facade annotation; leave the ExceptionHandler contract and forwarding fake unchanged.
+
+Inside Head's existing handler-resolution callback, register this hook using the already-resolved manager and set its coroutine-local status from the response. Type the callback argument as the ExceptionHandler contract and check each optional hook with `is_callable` before registering it, preserving forwarding fakes and custom handlers without those methods. This ensures Inertia's `handleExceptionsUsing()` and application `respondUsing()` callbacks render metadata for the actual response status with the framework handler. Handler remains independent of Head. Document the additive hook in the errors guide. Code that renders Head inside a custom exception renderer before returning its response still supplies the intended status explicitly.
+
+The Head regression throws its exceptions from an aliased route middleware closure: route actions deliberately unwrap `HttpResponseException` without invoking Handler. Preserve that routing behavior. Fix `TestResponse::decodeResponseJson()` to read its optional stored exception with null coalescing, so invalid JSON in a plain Symfony response produces the intended assertion instead of an undefined-property error. Add one exact-message regression alongside its existing stored-exception identity test.
+
+## State ownership and replication
+
+| Object/state | Lifetime |
+| --- | --- |
+| HeadManager configuration/resolvers, TagRegistry, SchemaFactory, SchemaValidator, HeadRenderer | Worker-shared; no captured request or tenant. |
+| CurrentHead data and status | Coroutine-scoped binding; implements `ReplicableContext`. |
+| HeadData, HeadBuilder, TagBuilder hierarchy, SchemaObject hierarchy, ResolvedHead | Implement `Transient`; caller-owned mutable state must not be auto-singletoned. |
+| TagRenderer | Immutable configuration; worker sharing is safe. |
+| ErrorPages | Manager-owned boot configuration, constructed with its definition closure; no transient marker needed. |
+| Middleware, gateway decorator, macro/integration registrars | Worker-safe; retain only services/configuration, never request results. |
+
+A container-resolution probe confirmed the actual failure: resolving the unmarked builder/schema twice returns the first mutable instance. Test normal container resolution of a builder and schema, not every subclass separately. Use the existing `Hypervel\Contracts\Container\Transient` marker.
+
+Keep HeadManager's `request(): ?Request` signature and return `$this->app->make('request')`. Hypervel's binding returns the coroutine-local request or creates a request from `app.url` outside HTTP, preserving Laravel-style console/queue canonical rendering. Its guaranteed Request result makes upstream's `bound()` and `instanceof` guards redundant. Do not retain requests in the worker-shared manager. Resolve `CurrentHead` when needed rather than injecting a scoped object into the manager. Inertia detection reads its existing context state directly, as specified below.
+
+Replication is explicit, not part of every layer merge:
+
+```php
+// HeadData: clone builders only when an independent state copy is requested.
+public function replicate(): static
+{
+ $replica = clone $this;
+ foreach ($this->builders as $class => $builder) {
+ $replica->builders[$class] = clone $builder;
+ }
+ return $replica;
+}
+
+// CurrentHead: copy data and retain the current status.
+public function replicate(): static
+{
+ $replica = clone $this;
+ $replica->data = $this->data->replicate();
+ return $replica;
+}
+```
+
+Do not add a deep `HeadData::__clone()`: `merge()` and `asDefaults()` already clone layers repeatedly. Add `Schemas::__clone()` to clone each stored SchemaObject and rebuild its identity key with the cloned object's `spl_object_hash()`. Keep content keys for array schemas. Stale object keys can collide with reused identities after original objects die.
+
+Built-in schema setters normalize nested SchemaObject/DateTime values into arrays/scalars; do not add a generic recursive SchemaObject clone engine. Custom builders or schema subclasses holding additional mutable objects own their `__clone()` behavior. Raw foreign objects supplied as application data retain normal PHP object semantics.
+
+`flush()` clears runtime tags and status for the current coroutine. Fresh requests are naturally isolated. For sequential independent pages or tenant switches within one coroutine, the application calls `flush()` between pages; do not silently flush during arbitrary tenancy transitions or invent Head-specific tenant partitions.
+
+## Runtime extension points
+
+Add these replaceable nullable closure slots to HeadManager, with facade `@method` entries:
+
+```php
+public function resolveDefaultsUsing(?Closure $resolver): static;
+public function resolveErrorsUsing(?Closure $resolver): static;
+public function resolveCanonicalOriginUsing(?Closure $resolver): static;
+```
+
+Callback contracts: defaults `(HeadBuilder $head): mixed`; errors `(HeadBuilder $head, int $status): mixed`; canonical origin `(): ?string`. Return values from builder callbacks are ignored, like existing `defaults()`. Registration returns the manager and null removes the resolver. No callback stack or per-tenant resolver registry.
+
+Use fresh HeadData and the existing `define()` path on every resolution. Precedence:
+
+1. Eager defaults.
+2. Runtime defaults, marked `asDefaults()` to preserve fallback media and title suffix semantics.
+3. Route groups and route layers, in their existing sequence.
+4. Current runtime page data.
+5. Static error defaults/status data.
+6. Runtime error data, when the effective status is non-null.
+
+The explicit render status takes precedence over CurrentHead status. Do not impose a new `>= 400` restriction; upstream supports explicit registered statuses. Run the error resolver even when no static error definition matches. Do not memoize resolved defaults/errors: repeated renders and same-coroutine context changes must observe current data.
+
+Document boot-only registration for these hooks and existing shared mutators (`defaults`, `errors`, `inertiaGlobals`, `inertia`, `extend`, schema registration, and the matching registry/error-definition methods). Capture worker-safe services in provider boot, then read tenant/request data inside callbacks. Never capture the currently active tenant during boot or mutate global config per request.
+
+Use generic teams and their settings in public examples. Do not reference private packages, application code, or product plans. Example, using an application-owned worker-safe context service whose `settings()` reads already-loaded team settings:
+
+```php
+public function boot(TeamContext $teams): void
+{
+ Head::resolveDefaultsUsing(function (HeadBuilder $head) use ($teams): void {
+ $settings = $teams->settings();
+ $head->title($settings->name, suffix: ' - '.$settings->name)
+ ->description($settings->description)
+ ->canonical()
+ ->manifest($settings->manifestUrl);
+ });
+
+ Head::resolveErrorsUsing(function (HeadBuilder $head, int $status) use ($teams): void {
+ $head->title($teams->errorTitle($status));
+ });
+
+ Head::resolveCanonicalOriginUsing(fn (): ?string => $teams->canonicalOrigin());
+}
+```
+
+Callbacks can execute multiple times per response (Inertia sharing plus Blade rendering, or repeated explicit calls). Reuse loaded settings; Head adds no database/network round trips. Application logic can use all existing fluent metadata and schema APIs in these callbacks.
+
+Keep `inertiaGlobals()` static, server-owned document tags. Tenant branding, icons, manifests, and any changeable metadata belong in page-managed defaults/runtime data. A runtime globals hook would still leave stale client tags after navigation and is not the correct solution.
+
+## Canonical URLs
+
+Add `Canonical::resolveOriginUsing(?Closure $resolver): void`, with static nullable callback and `flushState()`. HeadManager delegates its canonical registrar here, so direct Canonical rendering and manager rendering share one policy.
+
+- Origin contract: `scheme://host[:port]`, without a path; null preserves normal request-derived behavior. Match UrlGenerator's no-argument callback style.
+- Apply only to automatic and relative canonical URLs. Explicit absolute HTTP(S) URLs, recognized case-insensitively, and protocol-relative URLs keep their own host. For protocol-relative URLs, use HTTPS when forced, otherwise the request scheme when available; retain the protocol-relative form when rendered directly without a request and HTTPS is not forced. Existing normalization options still apply.
+- Automatic canonical path preserves the request's base URL and path, omits query as upstream request `url()` does, and uses the runtime origin when available. Outside HTTP, HeadManager uses the `app.url` request; the resolver replaces only its origin. Console/queue callers supply the intended page path when rendering a specific page. Direct `Canonical::render(null)` preserves upstream behavior: automatic mode emits no canonical, even with an origin resolver, because no page path is known. Explicit relative paths can use the resolver origin.
+- Preserve constructor, `render(?Request)`, and `normalizeUrl(string, ?Request, bool, bool)` signatures and override dispatch.
+- Normalize only the URL path. Detach query/fragment without re-encoding them, normalize the path/root slash, then append the untouched suffix. `https://tenant.test/?page=2` must not become `https://tenant.test/?page=2/`. Preserve ports, encoded paths, and fragments. Do not build a general URL validator/parser abstraction.
+
+## Rendering correctness and bounded optimization
+
+Port Laravel Head PR #14 at `f95fbedff21ec78f586c198baf44be3b6b3c76af`: an existing Open Graph builder falls back to the resolved canonical URL when its URL is unset. Preserve explicit URLs and the requirement to opt into Open Graph. Follow the PR's optional `$url` parameters and render/array paths, retaining null/empty-string checks. Port all six upstream tests; existing canonical-origin coverage already verifies the underlying resolver. Document the output behavior in the Head guide and a concise README difference; keep the upstream PR reference in this plan, not the README. No porting-guide entry or new caching mechanism.
+
+Port the exact split-defaults regression from PR #16 at `31874035fe56b2ed5285bfe4b72d3046d5ed773b`: separate site-name and default-image registrations followed by a page image, without a runtime resolver. Preserve the existing per-media provenance implementation. PR #17's Octane container switching and #18's lazy replacements for eager configuration do not apply; retain coroutine-local resolution and the explicit runtime resolvers.
+
+Port PR #15 at `e5573433e79298403844b2e65469e5713b9fda08` with its existing typed-helper conventions: Event location/image/performer/status/attendance-mode methods, Place, and EventStatus/EventAttendanceMode enums. Preserve its parameter names, enum cases and values, enum-only setters, concrete performer union, and string-location conversion into Place data. These follow Offer, Article and Product; raw schema values remain supported through `SchemaObject::set()`. Add Place to SchemaFactory and its factory annotations, regenerate the Schema facade, and port every EventSchemaTest case using cp-then-modify. Document the helpers, enum use, string-location meaning and `set()` for raw values in the Head guide, with a structured address example. No extra schema classes, normalization layer, validation engine or special typing convention. These additions require no new worker state or database work.
+
+| Change | Implementation and reason |
+| --- | --- |
+| Open Graph fallback media | Track default provenance independently for image/video/audio. Higher-layer media replaces only default media of its own kind. If the overlay has no media of that kind, inherit base provenance; otherwise use overlay provenance. Preserve provenance when two defaults layers merge. Keep the existing `overlayMedia` signature and replacement policy; use `hasMedia()` in `isEmpty()` so the protected hook remains meaningful. |
+| Literal `"0"` metadata | Title/Description emission and OG/Twitter fallback checks must distinguish null/empty strings from `"0"`. Their `headArray()` filters must also preserve `"0"` instead of dropping it through truthiness. Keep existing empty-string omission where currently intentional. Test both rendered tags and arrays. |
+| Manifest override | `GenericLinks::link()` keys `rel=manifest` as one logical link, so a tenant/page URL replaces the inherited manifest. Render its Inertia identity consistently as a singleton. Keep multiple icon candidates and other repeatable links; no broad icon key rewrite or removal/tombstone API. |
+| Title ownership | In `uniqueInertiaElements()`, treat title as its own bucket and other tags by `data-inertia` key, matching Inertia core `collect()`. A title and `` both survive. Keep existing semantic ownership keys. |
+| Schema factory consistency | `HeadBuilder::schemaFactory()` uses `Container::getInstance()->make(SchemaFactory::class)` when no factory was supplied. Honor explicitly injected factories. Direct/DI-created builders then see registered custom types and reuse the worker factory. Container auto-creation permits standalone use. This is a bug fix; no user-doc escape-hatch section. |
+| Schema values | Magic setters use the sole supplied value regardless of its argument name, preserving explicit null; only an omitted argument means true. Infer types from the unchanged concrete class short name, without inheriting parent attributes. Bulk FAQ/breadcrumb helpers cast numeric array keys back to strings. The existing validator descends into nested SchemaObject values through `toArray()`; raw schema arrays otherwise retain their serialization and reference semantics. |
+| Pagination | Replace the complete relation set on overlay, including an empty set. Accept ordinary and cursor paginator contracts. Advertise no pagination route attribute because pagination is supplied through the runtime API. |
+| Feed route values | Accept bare URLs and lists of URLs as untitled RSS feeds. Keep URL-to-title maps and structured definitions; omitted or null structured titles mean untitled, while other supplied titles must be strings. Omit empty title attributes when rendering. |
+| Meta ownership | Preserve semantic keys and aliases for the normal inferred name/property attribute without media. Hash the full stored identity when the attribute is explicitly different or media is present, preventing distinct tags from colliding while retaining overrides of dedicated builders. |
+| Registry/input consistency | Make repeated registration of the same custom builder a no-op before checking other builders' route keys. Resource-hint scalar list entries require integer keys; reject ambiguous URL-to-string maps through the existing invalid-value path. Preserve other builders' established filtering of unusable list entries. |
+| Route attribute lookup | Memoize TagRegistry's route-attribute map on the instance, invalidate after successful `extend()`. Its key set is bounded by registered builder classes. No static cleanup for this instance-only cache. |
+| Schema reflection | Cache inferred SchemaType/class-derived type by concrete class, preserving explicit constructor types and protected `typeFromAttribute()` dispatch. Bounded by loaded schema classes; add `flushState()`. Do not cache tenant values or SchemaObject instances. |
+| Content hashes | Replace md5 in TagRenderer, MetaTags and array-schema keys with deterministic `xxh128`; preserve semantic keys. No random worker seed: keys must match across requests/workers and SSR/client handoff. These hashes are identifiers, not trust boundaries. |
+| JSON-LD rendering | Encode once in `TagRenderer::jsonLd()` with existing safe JSON flags, derive any default key from that same encoded string, then emit it. `Schemas::toTags()` passes the schema without pre-encoding for a redundant explicit key. Keep explicit supplied keys, escaping, invalid-UTF-8 failures, and schema validation behavior. Insertion-time array content hashing remains separate. |
+
+Do not cache combined route HeadData: sequential overlay order matters, especially for custom builders whose merge operations are not necessarily associative. Keep ResolvedHead local to each render; its simple lookups do not need another cache. No output cache, tenant-key cache, dynamic worker cache, database schema, or benchmark claims.
+
+## Inertia integration
+
+Keep ShareHead's ordinary lazy shared closure, custom prop name, partial-reload omission, and per-request registration. Do not replace it with `always()` or eagerly render before route/controller metadata is set. Detection must tolerate an absent optional package and absent state without creating either:
+
+```php
+return class_exists(InertiaState::class)
+ && (CoroutineContext::get(InertiaState::CONTEXT_KEY)->page ?? []) !== [];
+```
+
+`UniqueHeadGateway::dispatch()` returns null unchanged; otherwise:
+
+```php
+return new Response(
+ $this->renderer->withoutInertiaElements($response->head, $elements),
+ $response->body,
+);
+```
+
+Preserve exact-string removal of page-managed elements and non-overlapping JavaScript SSR tags, for either ordering of `@head` and ``. Upstream's supported setup removes JS title callbacks and overlapping client `
` tags. Do not add an HTML parser or protocol workaround for conflicting configuration.
+
+The decorator implements and forwards Hypervel's `ConfiguresSsrRequests::configureRequestUsing(?Closure $callback = null): void`, including reset with null. Throw the same clear unsupported-capability LogicException used by ResponseFactory if the underlying gateway lacks it. Preserve disable/path exclusions/health forwarding, subject to the owner-approved health diagnostic change above. No production changes to the Inertia adapter are currently required.
+
+Document `serverHead: true` (or matching custom prop string) at every `createInertiaApp()`, including a separate SSR entry if present; minimum Inertia client is 3.5. Move overlapping JS Head tags/static branding into Head and remove JS title callbacks. Use self-closing `` to avoid a competing fallback title. Partial reloads retain the previous full page's head by design; tenant/page navigation needing fresh head uses a full visit.
+
+## Routing editor and PHPStan support
+
+Port `_ide_helper.php` for editor discovery without runtime autoloading. Exclude it specifically from root source analysis to avoid duplicate routing class declarations; do not globally ignore Head errors.
+
+Ship `src/head/extension.neon` via package `extra.phpstan.includes`, following database. Register one `MethodsClassReflectionExtension` and one signature interface under `src/head/src/PHPStan/` (for example `WithHeadExtension` and `RoutingMacros`). The interface declares the full upstream named-argument signature once with precise PHPDocs. The extension recognizes `withHead` on Router, RouteRegistrar, Route, PendingResourceRegistration, PendingSingletonResourceRegistration, and the Route facade, including subclasses; preserve existing native methods and exact macro-name matching.
+
+Use ReflectionProvider to obtain the interface method and a package-owned `WithHeadMethodReflection implements MethodReflection`, following Database's `ForwardedFluentMethodReflection`. Forward signature metadata and construct public `FunctionVariant` instances with the native parameters, template maps, variadic flag and call-site variance, replacing only the return type. Pass the declaring class, return type and static flag from the extension. Avoid PHPStan's internal annotation reflection constructors. Router/facade return RouteRegistrar; the other four return the receiving routing type. Preserve subclass fluent types where runtime returns `$this`; Router still creates a base RouteRegistrar. Do not execute provider boot or runtime macros during analysis.
+
+Planning verification: class-level stub `@method` declarations recognize `withHead` but replace existing routing class PHPDocs, breaking existing `Route::get()` and `Router::middleware()` analysis. Stub native declarations do not add absent methods. A small extension using a native signature interface passed strict analysis for all six surfaces plus existing methods, and correctly rejected unknown `$heading` and integer `$title`. Use the extension, not a stub or copied routing PHPDoc inventories.
+
+Add the extension to root source/types configurations and its PHPStan directory to the existing type-fixture scan directories. `types/Head/Route.php` covers all chain positions, return types, valid named values/extensions, and existing routing methods. Use identifier-scoped expected errors with unmatched-ignore reporting in the types fixture for intentionally invalid names/types, following the permitted local suppression rule; no source weakening or global ignore.
+
+Keep five runtime macro signatures, editor signatures, and analysis signature synchronized. Replace `HeadRouteMacros::bound()`'s runtime `assert()` type narrowing with its accurate generic PHPDoc/local `@var`; retain its public signature. Do not invent runtime validation for a macro closure that the framework binds itself.
+
+Generate both facade docblocks through the repository's Facade Documenter. Put the built-in schema factory instance `@method` declarations on SchemaFactory so direct factory calls and generated facade methods share one source. Preserve `@mixin HeadManager` and add `@mixin SchemaFactory` to the respective facades. Use the existing `ignoredFacadeDocumenterMethods()` hook for Head's `defaults`, `errors`, `inertiaGlobals`, the three runtime resolvers, `schema`, `extend`, `when`, and `unless`, and Schema's `make` and `register`: their mixins retain callback, class-string and generic types that generated tags flatten. Explain that reason briefly on each hook; do not change the generator.
+
+Add `types/Head/Facades.php` with one assertion per mechanism: a generated built-in facade method, a direct schema factory call within `Head::schema()`, generic `Schema::make()`, inferred defaults-callback parameter, fluent `Head::when()`, and an expected invalid callback. Keep this distinct from routing macro fixtures.
+
+## Documentation and cleanup
+
+Copy upstream `references/laravel/docs/head.md` to `src/docs/head.md`, then adapt namespace, installation and framework terminology. Preserve feature coverage, examples and links; remove unsupported Livewire documentation/fixtures rather than claiming an integration Hypervel does not provide. Add Head to `src/docs/documentation.md` navigation and link from Inertia docs where it helps discovery.
+
+Add concise runtime-resolver, precedence, canonical-origin and coroutine-use sections, the callback cost guidance, and one note that custom mutable builder properties need clone handling when contexts are copied. Document canonical-origin registration only through `Head::resolveCanonicalOriginUsing()`, alongside the defaults/errors resolvers; `Canonical::resolveOriginUsing()` owns the implementation behind that delegate, not a second documented setup path. Explain that PHPStan users without extension auto-discovery include `vendor/hypervel/head/extension.neon`. Add `event` to the built-in schema factory list (the implementation already registers it). Inertia guidance follows the supported setup above. Keep examples package-agnostic; consuming applications need no changes as part of this port.
+
+Update `src/docs/porting-from-laravel.md` only for usage requiring actual adaptation: configure worker-shared Head defaults at boot and use runtime resolvers for request-specific defaults; link to feature docs. Do not catalogue bug fixes, hashes, readonly response allocation, or internal caches there. README differences only for approved lasting public contracts, not ordinary correctness fixes.
+
+Add an optional Head group to `AfterEachTestSubscriber`, using `callIfExists()` for Canonical and SchemaObject `flushState()`, and HeadManager `flushMacros()`. Existing routing cleanup owns routing macro resets. Leave RouteAttributeParser's monotonically increasing metadata-layer counter alone: only unique keys matter, tests compare layer values, and it retains no growing collection. Container teardown clears manager/registry instances; do not duplicate instance cleanup or add manual per-test static resets.
+
+Remove stale upstream framework references, dead code introduced by fixes, inaccurate comments, unused imports/dependencies, and discarded alternatives. Preserve load-bearing upstream comments and every applicable upstream test. No Boost resources or new compatibility shims.
+
+## File execution checklist
+
+During implementation, track remaining files individually and remove entries as completed. Paths below mirror upstream under `src/head/src/`:
+
+```text
+Concerns/BuildsHead.php
+CurrentHead.php
+Enums/ImageType.php
+Enums/Media.php
+Enums/OfferAvailability.php
+Enums/OgType.php
+Enums/RobotsRule.php
+Enums/TwitterCard.php
+ErrorPages.php
+Exceptions/InvalidSchema.php
+Facades/Head.php
+Facades/Schema.php
+HeadBuilder.php
+HeadData.php
+HeadManager.php
+HeadServiceProvider.php
+Inertia/Integration.php
+Inertia/ShareHead.php
+Inertia/UniqueHeadGateway.php
+Rendering/HeadRenderer.php
+Rendering/ResolvedHead.php
+Rendering/TagRenderer.php
+Routing/HeadRouteMacros.php
+Routing/RouteAttributeParser.php
+Schema/Article.php
+Schema/BlogPosting.php
+Schema/Brand.php
+Schema/Breadcrumbs.php
+Schema/Event.php
+Schema/Faq.php
+Schema/GenericSchemaObject.php
+Schema/Offer.php
+Schema/Organization.php
+Schema/Person.php
+Schema/Product.php
+Schema/SchemaFactory.php
+Schema/SchemaObject.php
+Schema/SchemaValidator.php
+Schema/WebPage.php
+Schema/WebSite.php
+SchemaType.php
+TagRegistry.php
+Tags/AlternateLinks.php
+Tags/Canonical.php
+Tags/Description.php
+Tags/FeedLinks.php
+Tags/GenericLinks.php
+Tags/GroupedTagBuilder.php
+Tags/MetaTags.php
+Tags/OpenGraph.php
+Tags/PaginationLinks.php
+Tags/PerformanceLinks.php
+Tags/Robots.php
+Tags/Schemas.php
+Tags/StringTagBuilder.php
+Tags/TagBuilder.php
+Tags/Title.php
+Tags/Twitter.php
+```
+
+Additional new files: package `composer.json`, `LICENSE.md`, `README.md`, `_ide_helper.php`, `extension.neon`, `src/PHPStan/{RoutingMacros,WithHeadExtension,WithHeadMethodReflection}.php`; documentation page and routing/facade type fixtures. SchemaFactory owns its magic-method docblock; both Head facades include the generation hooks described above. Existing-file changes: root Composer metadata, PHPStan source/types configurations, optional testing cleanup, TestResponse, Foundation handler/configuration, Exceptions facade, docs navigation/Inertia/errors/porting guide as above.
+
+Port tests using `cp` then convert Pest cases to PHPUnit, retaining upstream directories under `tests/Head/`. Shared TestCase is justified by fixture views/provider setup and extends Hypervel Testbench. Configure views in `defineEnvironment()`, use framework container methods and request context, and remove only the Livewire provider/test/layout. Convert Pest setup into the base/individual cases; do not retain `Pest.php` or add Pest dependencies.
+
+```text
+ArchTest.php
+Feature/ErrorPagesTest.php
+Feature/HeadBuilderTest.php
+Feature/HeadDataTest.php
+Feature/HeadManagerTest.php
+Feature/HeadServiceProviderTest.php
+Feature/InertiaCustomPropTest.php
+Feature/InertiaTest.php
+Feature/Rendering/HeadRendererTest.php
+Feature/Rendering/TagRendererTest.php
+Feature/Routing/RouteHeadTest.php
+Feature/Routing/WithHeadTest.php
+Feature/Schema/SchemaTest.php
+Feature/Tags/CanonicalTest.php
+Feature/Tags/GenericLinksTest.php
+Feature/Tags/MetaTagsTest.php
+Feature/Tags/OpenGraphTest.php
+Feature/Tags/PerformanceLinksTest.php
+Feature/Tags/PwaTest.php
+Feature/Tags/RobotsTest.php
+Feature/Tags/ThemeColorTest.php
+Feature/Tags/TitleTest.php
+Feature/Tags/TwitterTest.php
+Fixtures/ConflictingRouteAttribute.php
+Fixtures/JobPosting.php
+Fixtures/ReadingTime.php
+Fixtures/ResolvesHttpKernelEarly.php
+Fixtures/views/app-directives.blade.php
+Fixtures/views/app-inertia-head-first.blade.php
+Fixtures/views/app-nested.blade.php
+Fixtures/views/app.blade.php
+Fixtures/views/nested-head.blade.php
+Fixtures/views/errors/404.blade.php
+TestCase.php
+```
+
+Retain the upstream architecture assertions through a small PHPUnit/token-based check for strict types, debug calls and direct env calls; no architecture-test framework dependency. Add `CoroutineIsolationTest.php` for Hypervel-specific lifetime/replication tests; place other regressions into the corresponding upstream test file rather than duplicate custom suites.
+
+## Verification plan
+
+Run each created/changed test class immediately, then affected package suites; all test/lint/analysis commands sequential, bounded by execution timeouts, from worktree root. No benchmarks without fresh owner confirmation that the machine is idle.
+
+| Area | Required behavior checks |
+| --- | --- |
+| Upstream parity | Every applicable upstream case, named arguments, route cache/serialization, extension validation, schema validation/logging, and Blade directive status behavior. |
+| Resolution | Eager callbacks remain eager; runtime callbacks are lazy, replaceable/clearable, use fresh data, and obey all six layers. Cover static/runtime error interaction, explicit status and unmapped error status. Repeated resolves after changed context observe new values. |
+| Container/coroutines | Two resolutions of builder/schema are independent. Interleaved tenant A/B requests do not mix runtime tags, errors, origin, or branding. Copied parent/child/sibling contexts independently mutate builder/schema data and retain copied status; clone schema identity keys match stored objects. Same-coroutine `flush()` clears page data/status while retaining boot defaults. |
+| Canonical | Auto and relative URLs on tenant aliases/custom ports/base paths, absolute external URLs, resolver null/reset, console/queue `app.url` fallback with and without a runtime origin, no automatic canonical for a null request even with a resolver, explicit relative paths with a resolver and no request, HTTPS flag, trailing slash, query and fragment preservation. |
+| OG/zero values | Default image/video/audio replacement independently; repeated defaults and runtime defaults retain provenance; ordinary page media still accumulates by URL. `"0"` survives title/description, inferred/explicit OG/Twitter, and array output. |
+| Links/schema | One manifest after inheritance/override, multiple valid icon candidates, schema clone isolation, explicit factory precedence and registered factory lookup, class inference cache with explicit type bypass, existing schema validation and JSON escaping/errors. |
+| PR regressions | Named/null magic setters, concrete multiword schema names, numeric bulk labels, nested schema-object validation, independent meta attributes and dedicated-builder overrides, complete pagination replacement and cursor input, untitled feeds, idempotent extensions, and invalid scalar resource-hint maps. |
+| Inertia protocol | Initial Blade and JSON page output/custom prop; partial reload omission; stable data-inertia keys; title plus meta-title; SSR enabled/disabled/null; both Blade ordering fixtures; preserve non-overlapping JS head and SSR body; wrapped response remains untouched. Forward SSR configurator/reset, disable/exclude/health and unsupported-capability diagnostics. |
+| Provider availability | Head without Inertia provider; installed Inertia classes with discovery disabled; enabled integration; handler and kernel resolved before Head boot. Testbench defaults to disabled discovery and supports explicit `getPackageProviders()`; use those boundaries rather than root development discovery assumptions. |
+| Exception responses | Foundation: one ordering test verifies the actual response after request-local replacement, argument identity, ignored observer returns, and execution before `respondUsing()`. Head: one data-provider test covers embedded responses, `Responsable`, exception `render()`, and an earlier application render callback through Inertia's exception hook; verify error titles and runtime resolver status. Preserve ordinary Blade/abort coverage. |
+| Static analysis/editor | Root source analysis and strict types fixtures; six routing surfaces, chaining/subclasses, named params, pre-existing routing PHPDocs unaffected. Full signature parity against runtime/editor definitions. |
+
+Use fake SSR gateways and coroutine-local tenant fixtures; no database/network service is necessary for Head tests. Verify emitted strings and ownership behavior against the inspected Inertia JS contract through PHP assertions. No wire-protocol changes or new JavaScript test harness are needed.
+
+For Head-only corrections, run `composer lint:fix`, `composer analyse`, the Head suite and affected facade checks sequentially. Run broader suites only for changes affecting shared framework behavior or unresolved evidence; do not repeat a full run solely because it exposed a metadata failure. Respect configured external-service availability and report skipped coverage accurately. Investigate failures at their owning boundary; do not weaken tests/types or add broad suppressions.
+
+Finally perform a fresh complete diff review for API parity, correctness, coroutine ownership, bounded caches, unnecessary work, Laravel ergonomics, and stale material. Request peer code review at the owner-approved implementation completion boundary with actual verification results. Resolve all findings and any new approval items, summarize in chat, and use Codesonic notification only as a ping to check chat.
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/docs/documentation.md b/src/docs/documentation.md
index 3550c286ad..d07390cf27 100644
--- a/src/docs/documentation.md
+++ b/src/docs/documentation.md
@@ -102,6 +102,7 @@
- [API Client](/docs/{{version}}/api-client)
- [Fortify](/docs/{{version}}/fortify)
- [gRPC](/docs/{{version}}/grpc)
+ - [Head](/docs/{{version}}/head)
- [Horizon](/docs/{{version}}/horizon)
- [Inertia](/docs/{{version}}/inertia)
- [Nested Set](/docs/{{version}}/nested-set)
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/docs/head.md b/src/docs/head.md
new file mode 100644
index 0000000000..20a594e84d
--- /dev/null
+++ b/src/docs/head.md
@@ -0,0 +1,833 @@
+# Head
+
+- [Introduction](#introduction)
+- [Installation](#installation)
+- [Quickstart](#quickstart)
+- [Resolution Precedence](#resolution-precedence)
+- [Defining Metadata](#defining-metadata)
+ - [Defaults](#defaults)
+ - [Runtime Defaults](#runtime-defaults)
+ - [Route Metadata](#route-metadata)
+ - [Runtime Metadata](#runtime-metadata)
+ - [Error Pages](#error-pages)
+- [Open Graph](#open-graph)
+ - [X / Twitter Cards](#twitter-cards)
+- [Theme Colors](#theme-colors)
+- [Application Metadata and Icons](#app-metadata-and-icons)
+- [Progressive Web Apps](#progressive-web-apps)
+- [Performance and Discovery](#performance-and-discovery)
+- [Custom Tags](#custom-tags)
+- [Schemas](#schemas)
+ - [Events](#events)
+ - [Breadcrumbs](#breadcrumbs)
+ - [FAQs](#faqs)
+ - [Custom Schemas](#custom-schemas)
+- [Rendering](#rendering)
+ - [Blade](#blade)
+ - [Inertia](#inertia)
+ - [Static Analysis](#static-analysis)
+
+
+## Introduction
+
+Head provides a fluent API for managing your application's document `` element, including title and meta tags, Open Graph metadata, canonical URLs, robots directives, performance hints, and structured data. It works with Blade and Inertia.
+
+
+## Installation
+
+You may install Head using the Composer package manager:
+
+```shell
+composer require hypervel/head
+```
+
+
+## Quickstart
+
+Register site-wide defaults in a service provider:
+
+```php
+use Hypervel\Head\Facades\Head;
+use Hypervel\Head\HeadBuilder;
+
+Head::defaults(fn (HeadBuilder $head) => $head
+ ->title('Hypervel', suffix: ' - Hypervel')
+ ->description('Build something great.'));
+```
+
+Set page-specific metadata at runtime:
+
+```php
+Head::title($post->title)
+ ->description($post->description);
+```
+
+Render the resolved tags in your layout:
+
+```blade
+
+ @head
+
+```
+
+
+## Resolution Precedence
+
+Page metadata resolves in the following order, from lowest to highest priority:
+
+1. Page defaults registered with `defaults()`
+2. Runtime defaults
+3. Route group and route metadata, in their declared order
+4. Runtime page metadata
+5. Registered error metadata
+6. Runtime error metadata
+
+Higher layers replace lower layers field by field. For example, a runtime title replaces the route title without replacing the route description. The sections that follow describe how to set metadata at each layer. For information about rendering the resolved metadata in Blade and Inertia, see [Rendering](#rendering).
+
+
+## Defining Metadata
+
+Head allows you to define metadata using site-wide defaults, route metadata, runtime calls, and error page definitions.
+
+
+### Defaults
+
+Register page defaults in a service provider's `boot` method. The callback runs immediately, and its values are shared by every request handled by the worker:
+
+```php
+use Hypervel\Head\Enums\OgType;
+use Hypervel\Head\Facades\Head;
+use Hypervel\Head\HeadBuilder;
+
+Head::defaults(function (HeadBuilder $head): void {
+ $head
+ ->title('Hypervel', suffix: ' - Hypervel')
+ ->description('Build something great.')
+ ->canonical()
+ ->og(siteName: 'Hypervel', type: OgType::Website)
+ ->searchableByRobots()
+ ->preconnect('https://fonts.example.com');
+});
+```
+
+Defaults are the lowest-priority page metadata layer. If no route, runtime, or error metadata sets a title, `Hypervel` renders as-is. When a higher layer sets a page title, the inherited suffix is applied, so `Head::title('About')` renders `About - Hypervel`. Pass `exact: true` for titles that should ignore an inherited prefix or suffix.
+
+Calling `Head::canonical()` renders a canonical URL using the current request URL. To set an explicit URL, pass a string such as `Head::canonical('/about')`. Canonical URLs are normalized to `https` by default; pass `forceHttps: false` to preserve the request scheme.
+
+Robots directives may be passed as a raw string, as `RobotsRule` enum cases, or as a list mixing both forms. Lists are rendered as comma-separated directives, so `Head::robots([RobotsRule::NoIndex, RobotsRule::NoFollow])` renders `noindex, nofollow`.
+
+For convenience, the `searchableByRobots` method renders `all`, while the `hiddenFromRobots` method renders `none`.
+
+
+### Runtime Defaults
+
+When defaults depend on the current request, register a callback with `resolveDefaultsUsing`. For example, an application may choose its title, description, and manifest from the current team's settings:
+
+```php
+use App\Support\TeamContext;
+use Hypervel\Head\Facades\Head;
+use Hypervel\Head\HeadBuilder;
+
+public function boot(TeamContext $teams): void
+{
+ Head::resolveDefaultsUsing(function (HeadBuilder $head) use ($teams): void {
+ $settings = $teams->settings();
+
+ $head->title($settings->name, suffix: ' - '.$settings->name)
+ ->description($settings->description)
+ ->canonical()
+ ->manifest($settings->manifestUrl);
+ });
+
+ Head::resolveErrorsUsing(function (HeadBuilder $head, int $status) use ($teams): void {
+ $head->title($teams->errorTitle($status));
+ });
+
+ Head::resolveCanonicalOriginUsing(fn (): ?string => $teams->canonicalOrigin());
+}
+```
+
+In this example, `TeamContext` is an application service that reads the current team's already-loaded settings from coroutine-local storage. Register the callbacks during boot and read request-specific values inside them. Do not capture a particular team during boot or change global configuration while handling a request.
+
+Runtime defaults override registered defaults and remain fallbacks for route and page metadata. The error callback runs after registered error metadata whenever a status has been set, including statuses without a registered definition. Passing a status explicitly to `Head::render()` overrides the current status.
+
+The canonical origin callback returns a value such as `https://team.example.com:8443`, without a path, or `null` to use the request's origin. It applies to automatic and relative canonical URLs; an explicit absolute URL keeps its own origin. Query strings and fragments in explicit URLs are preserved. Outside an HTTP request, automatic canonical URLs use `app.url`; supply the page path explicitly when rendering a particular page from a command or job.
+
+Each registration replaces the previous callback. Pass `null` to remove it. Callbacks may run more than once per response, such as when Inertia shares metadata and Blade renders the layout. Reuse settings already loaded for the request instead of querying the database each time.
+
+Page metadata and response status are isolated between concurrent requests. If one coroutine renders several independent pages, call `Head::flush()` between pages to clear its page metadata and status. Registered defaults and callbacks remain available.
+
+Custom tag builders or schema subclasses that hold mutable objects should implement `__clone()` when those objects need independent copies in child coroutines created with copied context. Built-in builders and schemas handle this automatically.
+
+
+### Route Metadata
+
+You may define metadata directly on routes, which is especially useful for semi-static pages whose metadata is known ahead of time.
+
+
+#### Routes and Groups
+
+```php
+Route::view('/contact', 'contact')
+ ->name('contact')
+ ->withHead(
+ title: 'Contact Us',
+ description: 'Get in touch.',
+ );
+```
+
+Shared route metadata may be applied to a group at any position in the chain:
+
+```php
+Route::withHead(robots: 'noindex, nofollow')
+ ->prefix('admin')
+ ->name('admin.')
+ ->group(function () {
+ Route::get('/dashboard', DashboardController::class)
+ ->name('dashboard')
+ ->withHead(title: 'Dashboard');
+ });
+```
+
+You may also define metadata for resource and singleton routes:
+
+```php
+Route::resource('posts', PostController::class)->withHead(
+ robots: 'index, follow',
+);
+
+Route::singleton('profile', ProfileController::class)->withHead(
+ title: 'Your Profile',
+);
+```
+
+The `withHead` method stores plain arrays through Hypervel's native route metadata API. It is equivalent to calling the `metadata` method with the attributes nested under a `head` key, so the metadata remains compatible with cached routes.
+
+The named arguments are intentionally limited to Head's built-in route properties so editors and static analysis can catch misspelled names. Route attributes registered by custom tag builders may be passed through `extensions`:
+
+```php
+Route::get('/article', ArticleController::class)->withHead(
+ title: 'Article',
+ extensions: ['readingTime' => 4],
+);
+```
+
+
+#### Supported Properties
+
+The supported route properties map to the same names as the fluent builder methods:
+
+| Category | Properties |
+| --- | --- |
+| Document | `title`, `description`, `canonical`, `robots` |
+| Application metadata | `themeColor`, `applicationName`, `colorScheme`, `referrer`, `viewport`, `appleWebAppTitle`, `webAppCapable`, `appleWebAppStatusBarStyle` |
+| Social | `og`, `ogImage`, `ogVideo`, `ogAudio`, `twitter`, `twitterImage` |
+| Performance | `preload`, `prefetch`, `preconnect`, `dnsPrefetch` |
+| Discovery | `alternates`, `feed`, `icon`, `favicon`, `appleTouchIcon`, `appleTouchStartupImage`, `maskIcon`, `manifest` |
+| Structured data | `schema` |
+| Custom tags | `meta`, `link` |
+
+Nested option names use the same `camelCase` naming as the fluent API, such as `forceHttps`, `siteName`, and `secureUrl`.
+
+Repeatable properties, such as `ogImage`, `preload`, `feed`, `schema`, `icon`, and `appleTouchStartupImage`, accept either a single value or a list.
+
+
+### Runtime Metadata
+
+When a value isn't known until a request arrives, such as the title of a post being viewed, you may set it at runtime:
+
+```php
+use Hypervel\Head\Facades\Head;
+
+public function __invoke(Post $post): Response
+{
+ Head::title($post->title);
+
+ // ...
+}
+```
+
+Runtime calls made via the `Head` facade override route metadata for request-dependent data. Controllers and actions are the most common places to make these calls:
+
+```php
+use App\Models\Post;
+use Hypervel\Head\Facades\Head;
+
+public function show(Post $post)
+{
+ Head::title($post->title)
+ ->description($post->description);
+
+ return view('posts.show', ['post' => $post]);
+}
+```
+
+Multiple runtime calls are merged in the order they run. For single-value fields such as title, description, canonical URL, and robots directives, the later call takes precedence. Repeatable fields retain multiple entries, but adding the same key again updates the earlier entry. For the `ogImage` method, the URL is the key:
+
+```php
+Head::ogImage('/images/cover.jpg', alt: 'Draft cover')
+ ->ogImage('/images/gallery.jpg', alt: 'Gallery image')
+ ->ogImage('/images/cover.jpg', alt: 'Final cover', width: 1200, height: 630);
+```
+
+```html
+
+
+
+
+
+
+```
+
+Open Graph media inherited from your defaults acts as a fallback. When route, runtime, or error metadata defines its own media of the same type, the default media is replaced instead of merged, so a page's `og:image` takes precedence over a site-wide default image.
+
+You may fluently define conditional metadata using the `when` and `unless` methods:
+
+```php
+Head::title($post->title)
+ ->when($post->isDraft(), fn ($head) => $head->hiddenFromRobots());
+```
+
+
+### Error Pages
+
+Typically, you should register error metadata within the `boot` method of your application's `AppServiceProvider` class:
+
+```php
+use Hypervel\Head\ErrorPages;
+use Hypervel\Head\Facades\Head;
+
+/**
+ * Bootstrap any application services.
+ */
+public function boot(): void
+{
+ Head::errors(function (ErrorPages $errors): void {
+ $errors->defaults(robots: 'noindex, follow');
+
+ $errors->status(
+ 404,
+ title: 'Page Not Found',
+ description: 'The page you are looking for could not be found.',
+ );
+ });
+}
+```
+
+The `defaults` and `status` methods also accept the same fluent builder callback used by `Head::defaults()`:
+
+```php
+use Hypervel\Head\ErrorPages;
+use Hypervel\Head\Facades\Head;
+use Hypervel\Head\HeadBuilder;
+
+Head::errors(function (ErrorPages $errors): void {
+ $errors->status(404, fn (HeadBuilder $head) => $head
+ ->title('Page Not Found')
+ ->description('The page you are looking for could not be found.'));
+});
+```
+
+When a response is rendered for a registered error status, that metadata takes precedence over page metadata. A [runtime error callback](#runtime-defaults) may customize it further.
+
+With the framework exception handler, Head automatically detects the response status when rendering an error view or executing a response callback such as Inertia's `handleExceptionsUsing()` method. If an `$exceptions->render()` callback or an exception's own `render()` or `toResponse()` method renders Head before returning its response, call `Head::status(404)` first or pass the status to `@head(404)` so the error metadata is applied.
+
+
+## Open Graph
+
+You may set Open Graph properties using the `og` method. Repeatable media may be added using the top-level methods, which accept named arguments directly:
+
+```php
+use Hypervel\Head\Enums\ImageType;
+use Hypervel\Head\Enums\OgType;
+
+Head::og(type: OgType::Article, title: $post->title)
+ ->ogImage($post->hero_image_url)
+ ->ogImage(
+ $post->gallery_image_url,
+ alt: $post->gallery_image_alt,
+ width: 1200,
+ height: 630,
+ type: ImageType::Jpeg,
+ );
+```
+
+The `ogImage`, `ogVideo`, and `ogAudio` methods accept a URL as their first argument, along with optional named arguments such as `alt`, `width`, `height`, `type`, and `secureUrl` where supported by the Open Graph specification.
+
+You may pass image MIME types as `ImageType` enum cases anywhere the API accepts an image `type`, such as `ImageType::Svg`, `ImageType::Png`, `ImageType::Jpeg`, and `ImageType::Webp`.
+
+> [!NOTE]
+> Document `title`, `description`, and canonical URL values automatically fill missing `og:title`, `og:description`, and `og:url` values.
+
+For a single Open Graph image with no other attributes, you may pass the `image` named argument to the `og` method:
+
+```php
+Head::og(
+ type: OgType::Website,
+ title: $page->title,
+ description: $page->description,
+ image: $page->og_image_url,
+);
+```
+
+The `og(image: ...)` and `ogImage(...)` calls write to the same underlying image list, so you may use whichever is more expressive at the call site. You may use the [`meta`](#custom-tags) method for custom Open Graph extensions such as product or article properties.
+
+
+### X / Twitter Cards
+
+To render X / Twitter cards from the same title, description, and image used by Open Graph, register `twitter()` in your defaults:
+
+```php
+use Hypervel\Head\Enums\TwitterCard;
+use Hypervel\Head\Facades\Head;
+use Hypervel\Head\HeadBuilder;
+
+Head::defaults(fn (HeadBuilder $head) => $head->twitter(
+ card: TwitterCard::SummaryWithLargeImage,
+));
+```
+
+Then set page-level metadata:
+
+```php
+Head::title('Introducing Hypervel Head')
+ ->description('A fluent API for Hypervel document head metadata.')
+ ->ogImage('https://example.com/social.jpg', alt: 'Introducing Hypervel Head');
+```
+
+This renders matching Twitter tags:
+
+```html
+
+
+
+
+
+```
+
+You may customize individual pages with explicit Twitter values:
+
+```php
+Head::twitter(title: $post->social_title)
+ ->twitterImage($post->social_image_url, alt: $post->title);
+```
+
+Route metadata accepts `twitter` and `twitterImage`.
+
+
+## Theme Colors
+
+You may set theme colors globally, per route, or at runtime:
+
+```php
+Head::themeColor('#0f172a');
+```
+
+This renders a `` tag. For media-specific theme colors, you may use the `Media` enum:
+
+```php
+use Hypervel\Head\Enums\Media;
+
+Head::themeColor('#ffffff', media: Media::Light)
+ ->themeColor('#111827', media: Media::Dark);
+```
+
+The `Media` enum also includes `Portrait` and `Landscape`. The `media` argument also accepts a custom media query string.
+
+Route metadata supports a single theme color through the same `camelCase` key:
+
+```php
+Route::view('/dashboard', 'dashboard')->withHead(
+ themeColor: '#0f172a',
+);
+```
+
+
+## Application Metadata and Icons
+
+Head includes methods for common browser and application metadata:
+
+```php
+use Hypervel\Head\Enums\ImageType;
+use Hypervel\Head\Enums\Media;
+
+Head::applicationName('Hypervel')
+ ->colorScheme('light dark')
+ ->referrer('strict-origin-when-cross-origin')
+ ->viewport('width=device-width, initial-scale=1')
+ ->appleWebAppTitle('Hypervel')
+ ->webAppCapable()
+ ->appleWebAppStatusBarStyle('black')
+ ->favicon('/favicon.svg', type: ImageType::Svg)
+ ->icon('/favicon-32x32.png', type: ImageType::Png, sizes: '32x32')
+ ->appleTouchIcon('/apple-touch-icon.png', sizes: '180x180')
+ ->appleTouchStartupImage('/launch.png', media: Media::Portrait)
+ ->maskIcon('/safari-pinned-tab.svg', color: '#111827')
+ ->manifest('/site.webmanifest');
+```
+
+The `favicon` method is an alias for the `icon` method and accepts the same `type`, `sizes`, and `media` arguments.
+
+Route metadata uses the same names:
+
+```php
+use Hypervel\Head\Enums\ImageType;
+use Hypervel\Head\Enums\Media;
+
+Route::view('/dashboard', 'dashboard')->withHead(
+ applicationName: 'Hypervel',
+ colorScheme: 'light dark',
+ appleWebAppTitle: 'Hypervel',
+ webAppCapable: true,
+ appleWebAppStatusBarStyle: 'black',
+ favicon: [
+ ['href' => '/favicon.svg', 'type' => ImageType::Svg],
+ ['href' => '/favicon-32x32.png', 'type' => ImageType::Png, 'sizes' => '32x32'],
+ ],
+ appleTouchIcon: ['href' => '/apple-touch-icon.png', 'sizes' => '180x180'],
+ appleTouchStartupImage: ['href' => '/launch.png', 'media' => Media::Portrait],
+ manifest: '/site.webmanifest',
+);
+```
+
+
+## Progressive Web Apps
+
+The `pwa` method configures the common document `` tags needed for an installable web app:
+
+```php
+Head::pwa(
+ name: 'Hypervel',
+ manifest: '/site.webmanifest',
+ themeColor: '#0f172a',
+ appleTouchIcon: '/apple-touch-icon.png',
+ appleWebAppStatusBarStyle: 'black',
+);
+```
+
+This renders the application name, web application manifest link, and iOS standalone metadata. If provided, the theme color, Apple status bar style, and Apple touch icon are also rendered. Creating the web application manifest and registering a service worker remain your application's responsibility.
+
+You may use the `pwa` method in defaults or runtime metadata. Route metadata supports the individual properties shown above.
+
+
+## Performance and Discovery
+
+Head renders performance hints, pagination links, locale alternates, and feed discovery:
+
+```php
+Head::preload(asset('fonts/inter.woff2'), as: 'font', crossorigin: true)
+ ->prefetch(asset('images/next.webp'))
+ ->preconnect('https://cdn.example.com')
+ ->dnsPrefetch('https://analytics.example.com')
+ ->paginate($posts)
+ ->alternates([
+ 'en' => 'https://example.com/en/about',
+ 'fr' => 'https://example.com/fr/about',
+ 'x-default' => 'https://example.com/about',
+ ])
+ ->feed('/feed', title: 'Hypervel RSS')
+ ->feed('/feed.atom', type: 'atom', title: 'Hypervel Atom');
+```
+
+The `paginate` method accepts both ordinary and cursor paginators. Each call replaces the complete set of previous and next links, removing links that do not apply to the new page.
+
+Feed route metadata accepts a URL, a list of URLs, a URL-to-title map, or a structured definition with `href`, optional `title`, and optional `type`. A bare URL produces an untitled RSS feed. When calling `Head::feed()` directly, pass an empty title to omit the title attribute.
+
+For local assets, `preloadAsset()` and `prefetchAsset()` resolve the URL through the `asset()` helper and detect the `as` attribute from the file extension. Font preloads automatically include `crossorigin`, which the preload specification requires even for same-origin fonts:
+
+```php
+Head::preloadAsset('fonts/inter.woff2')
+ ->prefetchAsset('images/next.webp');
+```
+
+```html
+
+
+```
+
+You may pass `as` explicitly to override detection. The `preloadAsset` method will throw an exception when the `as` attribute cannot be detected from the extension because browsers ignore preloads without this attribute; the `prefetchAsset` method will simply omit it.
+
+
+## Custom Tags
+
+For tags without a dedicated method, use `meta()` and `link()`:
+
+```php
+Head::meta('format-detection', 'telephone=no')
+ ->meta('article:author', $post->author->name)
+ ->link('search', '/opensearch.xml', [
+ 'type' => 'application/opensearchdescription+xml',
+ 'title' => 'Hypervel Search',
+ ])
+ ->link('me', 'https://social.example.com/@hypervel');
+```
+
+You may include a media query on a meta tag when the browser should only apply the tag under matching conditions:
+
+```php
+use Hypervel\Head\Enums\Media;
+
+Head::meta('theme-color', '#ffffff', media: Media::Light)
+ ->meta('theme-color', '#111827', media: Media::Dark);
+```
+
+The `meta` method uses the `name` attribute for regular meta tags. For keys that typically use the `property` attribute, such as Open Graph (`og:`) or article metadata (`article:`), the method switches automatically:
+
+```php
+Head::meta('description', 'About Hypervel')
+ ->meta('og:title', 'About Hypervel');
+```
+
+```html
+
+
+```
+
+You may pass `property: true` or `property: false` to explicitly select either attribute.
+
+
+## Schemas
+
+Built-in schema builders cover the common JSON-LD types:
+
+```php
+use Hypervel\Head\Enums\OfferAvailability;
+use Hypervel\Head\Facades\Schema;
+
+Head::schema(
+ Schema::product()
+ ->name($product->name)
+ ->offers(
+ Schema::offer()
+ ->price($product->price)
+ ->currency('USD')
+ ->availability(OfferAvailability::InStock)
+ )
+);
+```
+
+The built-in factory methods are `article`, `blogPosting`, `product`, `offer`, `brand`, `breadcrumbs`, `event`, `faq`, `organization`, `person`, `place`, `webPage`, and `webSite`. Unknown factory methods create a generic schema object, so you can still express custom schema.org types.
+
+When JSON-LD schema data is invalid, Head throws an exception in non-production environments and logs a warning in production.
+
+
+### Events
+
+The event builder provides methods for dates, locations, images, performers, organizers, and offers. Use the `EventStatus` and `EventAttendanceMode` enums to describe the event's status and how people attend:
+
+```php
+use Hypervel\Head\Enums\EventAttendanceMode;
+use Hypervel\Head\Enums\EventStatus;
+use Hypervel\Head\Facades\Head;
+use Hypervel\Head\Facades\Schema;
+
+Head::schema(
+ Schema::event()
+ ->name('Community Workshop')
+ ->startDate($event->starts_at)
+ ->location(
+ Schema::place()->name('Community Center')->address([
+ '@type' => 'PostalAddress',
+ 'streetAddress' => '123 Main Street',
+ 'addressLocality' => 'Portland',
+ 'addressRegion' => 'OR',
+ 'postalCode' => '97201',
+ 'addressCountry' => 'US',
+ ])
+ )
+ ->image($event->image_url)
+ ->eventStatus(EventStatus::Scheduled)
+ ->eventAttendanceMode(EventAttendanceMode::Offline)
+);
+```
+
+Passing a string to `location` creates a `Place` with that name. You may also pass another schema object, such as `Schema::virtualLocation()->url($event->stream_url)`. The `performer` method accepts a person, an organization, or a list of those builders, and `image` accepts a URL or a list of URLs.
+
+For raw values or shapes outside the typed helpers, every schema builder provides a `set` method. For example, `$eventSchema->set('location', [$place, $virtualLocation])` defines multiple locations, and `$eventSchema->set('location', 'Venue')` preserves a plain text location.
+
+
+### Breadcrumbs
+
+Breadcrumb items may be added one at a time or in bulk. Positions are assigned automatically in the order the items are added:
+
+```php
+Head::schema(
+ Schema::breadcrumbs()->items([
+ 'Home' => route('home'),
+ 'Shop' => route('shop.index'),
+ 'Shoes' => route('shop.category', 'shoes'),
+ ])
+);
+```
+
+You may use the `item` method to append a single breadcrumb item:
+
+```php
+Schema::breadcrumbs()
+ ->item('Home', route('home'))
+ ->item('Shop', route('shop.index'));
+```
+
+
+### FAQs
+
+FAQ entries follow the same pattern. You may add them one at a time using the `question` method or in bulk using the `questions` method:
+
+```php
+Head::schema(
+ Schema::faq()->questions([
+ 'What is Hypervel Head?' => 'A fluent API for managing the document head.',
+ 'Is it free?' => 'Yes, it is open source.',
+ ])
+);
+```
+
+
+### Custom Schemas
+
+You may explicitly register custom schema types during provider boot:
+
+```php
+use DateTimeInterface;
+use Hypervel\Head\Facades\Schema;
+use Hypervel\Head\Schema\SchemaObject;
+use Hypervel\Head\SchemaType;
+
+#[SchemaType('JobPosting')]
+class JobPosting extends SchemaObject
+{
+ public function title(string $title): static
+ {
+ return $this->set('title', $title);
+ }
+
+ public function datePosted(DateTimeInterface|string $date): static
+ {
+ return $this->date('datePosted', $date);
+ }
+}
+
+Schema::register(JobPosting::class);
+
+Head::schema(
+ Schema::jobPosting()
+ ->title('Senior Hypervel Developer')
+ ->datePosted(now())
+);
+```
+
+
+## Rendering
+
+Head resolves page metadata into tags for the current response. How these tags are rendered depends on your application stack.
+
+The HTML renderer powers the `@head` directive and the rendered elements that Head shares with Inertia via the `head` prop. The array renderer powers `Head::toArray()` for applications that need the resolved metadata as structured data.
+
+
+### Blade
+
+Render the accumulated tags in your layout's `` with the `@head` directive:
+
+```blade
+
+
+ @head
+
+```
+
+The `@head` directive renders synchronously, so you should define page metadata before the layout is rendered.
+
+
+### Inertia
+
+Use the same `@head` directive in your Inertia root template, alongside Inertia's own components:
+
+```blade
+
+
+
+ @head
+
+ @viteReactRefresh
+ @vite(['resources/css/app.css', 'resources/js/app.tsx'])
+
+
+
+
+
+
+```
+
+When the [Inertia adapter](/docs/{{version}}/inertia) is installed and its service provider is registered, Head automatically shares the page-managed head as an array of rendered element strings under a `head` prop on every page object:
+
+```json
+{
+ "props": {
+ "head": [
+ "Dashboard - Hypervel",
+ ""
+ ]
+ }
+}
+```
+
+Enable Inertia's `serverHead` option wherever your application calls `createInertiaApp()`. The option is available in Inertia 3.5 and later:
+
+```js
+createInertiaApp({
+ // ...
+ serverHead: true,
+});
+```
+
+Each page-managed element has a stable `data-inertia` key. The `@head` directive renders the initial document, after which Inertia adopts those elements and keeps them synchronized during standard visits, [instant visits](https://inertiajs.com/docs/v3/the-basics/instant-visits), and back and forward navigation. The elements are present in the initial HTML response, so crawlers and link-preview bots can read them without executing JavaScript. No client-side `` component is required.
+
+This works with or without [server-side rendering (SSR)](https://inertiajs.com/docs/v3/advanced/server-side-rendering). If your application has a separate SSR entry point, enable `serverHead` there too. Head automatically deduplicates page-managed elements between `@head` and ``, regardless of their order, while preserving other head elements produced by JavaScript SSR.
+
+> [!NOTE]
+> When adding Head to an existing Inertia application, remove title callbacks from `resources/js/app.tsx` and `resources/js/ssr.tsx`. Move overlapping tags from Inertia's [`` component](https://inertiajs.com/docs/v3/the-basics/title-and-meta) and static branding from your root template into Head. Use the self-closing `` shown above so a fallback title does not compete with Head's title.
+
+The `head` prop is omitted from partial reload responses, so Inertia retains the last full page's head. Use a full visit when changing pages or teams requires fresh metadata. Instant visits likewise retain the current head until the background response arrives. If your application already uses the `head` prop, change its name in a service provider:
+
+```php
+use Hypervel\Head\Facades\Head;
+
+public function boot(): void
+{
+ Head::inertia(prop: '_head');
+}
+```
+
+Then point Inertia at the same prop with `serverHead: '_head'` in both client and SSR entry points.
+
+
+#### Static Inertia Tags
+
+Most tags should live in defaults, route metadata, or runtime metadata so Head can resolve the right value for each page. Use Inertia globals only for document tags rendered in the first HTML response and left unchanged by Inertia for the rest of the session.
+
+Register them in a service provider with `Head::inertiaGlobals()`:
+
+```php
+use Hypervel\Head\Facades\Head;
+use Hypervel\Head\HeadBuilder;
+
+Head::inertiaGlobals(function (HeadBuilder $head): void {
+ $head
+ ->viewport('width=device-width, initial-scale=1')
+ ->colorScheme('light dark');
+});
+```
+
+Inertia globals are excluded from the `head` prop, rendered without `data-inertia` ownership attributes, and never updated after the first response. These globals are suitable for stable browser hints such as viewport and color scheme. Team branding, icons, manifests, and other values that may change belong in defaults, route metadata, or runtime metadata instead.
+
+Applications that need the resolved metadata as structured data instead of rendered tags may call `Head::toArray()`. The returned data includes titles, Open Graph values, JSON-LD schemas, and other resolved metadata.
+
+
+### Static Analysis
+
+Head includes editor declarations for its route macros and a PHPStan extension for checking their named arguments and fluent return types. PHPStan's extension installer discovers the extension automatically. Without extension discovery, include it in your PHPStan configuration:
+
+```neon
+includes:
+ - vendor/hypervel/head/extension.neon
+```
diff --git a/src/docs/inertia.md b/src/docs/inertia.md
index 530f3a52be..246d372f58 100644
--- a/src/docs/inertia.md
+++ b/src/docs/inertia.md
@@ -1953,6 +1953,8 @@ import AppHead from './AppHead'
### Server-Side Head Elements
+To manage titles, social metadata, canonical URLs, and structured data from PHP, use [Head](/docs/{{version}}/head#inertia). It renders the initial Blade document and shares metadata with Inertia for subsequent navigation, including defaults resolved from the current request.
+
You may wish to include default head elements like `` or `` tags in your root Blade template. This creates two challenges: preventing duplicates when [SSR](#server-side-rendering) is active, and ensuring the client-side `` component may replace them during navigation.
diff --git a/src/docs/porting-from-laravel.md b/src/docs/porting-from-laravel.md
index b9b8da7fce..1fca13616d 100644
--- a/src/docs/porting-from-laravel.md
+++ b/src/docs/porting-from-laravel.md
@@ -34,6 +34,7 @@
- [JSON:API Resources](#jsonapi-resources)
- [CSRF Protection](#csrf-protection)
- [Fortify](#fortify)
+ - [Head](#head)
- [Passport](#passport)
- [Pennant](#pennant)
- [Permission](#permission)
@@ -603,6 +604,11 @@ User models that use Fortify's `TwoFactorAuthenticatable` trait must also implem
Fortify ignores Laravel's `fortify.passwords` setting. Declare the password reset broker with the guard's `passwords` key in `config/auth.php` instead. See [password resets](/docs/{{version}}/fortify#password-resets). Laravel's deprecated `Laravel\Fortify\Rules\Password` rule is not available; use `Hypervel\Validation\Rules\Password`.
+
+### Head
+
+Head defaults, error definitions, and Inertia globals are shared for the worker lifetime. Register them during provider boot. Move request-dependent defaults and error definitions to `resolveDefaultsUsing()` and `resolveErrorsUsing()` so they read the current request when metadata is rendered. See [runtime defaults](/docs/{{version}}/head#runtime-defaults).
+
### Passport
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/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..565530d2e4
--- /dev/null
+++ b/src/head/README.md
@@ -0,0 +1,14 @@
+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).
+
+An unset `og:url` falls back to the canonical URL when a page defines Open Graph metadata; explicit Open Graph URLs take precedence. See [Open Graph](https://hypervel.org/docs/head#open-graph).
+
+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..0bb43b2b94
--- /dev/null
+++ b/src/head/src/Concerns/BuildsHead.php
@@ -0,0 +1,505 @@
+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 CursorPaginator|Paginator $paginator
+ */
+ public function paginate(Paginator|CursorPaginator $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/EventAttendanceMode.php b/src/head/src/Enums/EventAttendanceMode.php
new file mode 100644
index 0000000000..f7d440ba52
--- /dev/null
+++ b/src/head/src/Enums/EventAttendanceMode.php
@@ -0,0 +1,20 @@
+value;
+ }
+}
diff --git a/src/head/src/Enums/EventStatus.php b/src/head/src/Enums/EventStatus.php
new file mode 100644
index 0000000000..7f34153237
--- /dev/null
+++ b/src/head/src/Enums/EventStatus.php
@@ -0,0 +1,22 @@
+value;
+ }
+}
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\CursorPaginator|\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..1009070ef8
--- /dev/null
+++ b/src/head/src/Facades/Schema.php
@@ -0,0 +1,63 @@
+
+ */
+ 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..50526bbea8
--- /dev/null
+++ b/src/head/src/HeadServiceProvider.php
@@ -0,0 +1,83 @@
+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 (ExceptionHandler $handler): void {
+ $head = $this->app->make(HeadManager::class);
+
+ if (is_callable([$handler, 'renderable'])) {
+ $handler->renderable(function (Throwable $exception, Request $request) use ($head): void {
+ $head->status(
+ $exception instanceof HttpExceptionInterface ? $exception->getStatusCode() : 500
+ );
+ });
+ }
+
+ if (is_callable([$handler, 'beforeResponding'])) {
+ $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..ee5c55ae6f
--- /dev/null
+++ b/src/head/src/Inertia/Integration.php
@@ -0,0 +1,68 @@
+app->getProviders(InertiaServiceProvider::class) === []) {
+ return;
+ }
+
+ $this->decorateSsrGateway();
+ $this->registerMiddleware();
+ }
+
+ /**
+ * Share head elements during every request in coroutine-local Inertia state.
+ */
+ protected function registerMiddleware(): void
+ {
+ $prepend = static function (Kernel $kernel): void {
+ // Register the lazy prop before global middleware can render an error.
+ $kernel->prependMiddleware(ShareHead::class);
+ };
+
+ $this->app->afterResolving(Kernel::class, $prepend);
+
+ if ($this->app->resolved(Kernel::class)) {
+ $prepend($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