From 0893a0fba542b14738951f2e8c1838601e2d6577 Mon Sep 17 00:00:00 2001 From: Aure Dulvresse <147180124+AureDulvresse@users.noreply.github.com> Date: Tue, 6 Oct 2026 12:30:33 +0100 Subject: [PATCH 1/3] Fix stale CLI version display, add framework_version() helper The `php forge` banner and `php forge about` always showed "0.1.0"/a mislabeled app version regardless of the actually-installed marrow/framework version, because Console\Kernel and AboutCommand both read config('app.version') (the application's own version, itself defaulting to 0.1.0 in a fresh skeleton) rather than anything reflecting the framework package itself. Added framework_version(), resolved from Composer\InstalledVersions, and wired it into the Kernel's console banner and a new, correctly labeled "Framework" row in `about` (separate from "App"). Also fixed FrameworkExtension::getGlobals() reading $_ENV directly instead of config('app.*'), which made a config-only (non-env) app.name/version invisible to Twig's `app.*` global. Co-Authored-By: Claude Sonnet 5 --- CHANGELOG.md | 26 ++++++++++++++++++++++++++ src/Application.php | 11 +++++------ src/Console/Commands/AboutCommand.php | 14 ++++++++------ src/Support/helpers.php | 24 ++++++++++++++++++++++++ src/Template/FrameworkExtension.php | 8 ++++---- 5 files changed, 67 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 06a0576..7ca4640 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,32 @@ Les versions `0.1.x`/`0.2.0` (juin 2026) correspondent à la phase de prototypag ## [Unreleased] +## [2.5.0] - 2026-10-06 + +### Added + +- **`framework_version()`** (`Support\helpers.php`) — the installed `marrow/framework` version (e.g. `2.4.1`), + resolved from Composer's own runtime metadata (`Composer\InstalledVersions`), never hand-maintained. Distinct + from `app()->version()` (`config('app.version')`), which is the *application's* own version. + +### Fixed + +- **The `php forge` CLI banner always printed "Marrow 0.1.0"**, regardless of which `marrow/framework` version + was actually installed — `Console\Kernel` was constructed with `config('app.version', '0.1.0')`, the + *application's* version config (itself defaulting to `0.1.0` in a fresh skeleton), not the framework's. A + fresh `composer create-project marrow/skeleton` followed immediately by `php forge list` looked exactly like + a framework frozen since an early prototype. Now built from `framework_version()` instead — `php forge list` + on a real install correctly shows e.g. `Marrow 2.4.1`. +- **`php forge about` mislabeled the application's own version as "Framework"**, and never showed the actual + installed `marrow/framework` version anywhere. Now shows both, correctly separated: `Framework` (the real, + Composer-resolved version) and `App` (`config('app.name')` + `config('app.version')`, the app's own). + Also switched its four other reads from raw `$_ENV` to `config()`, matching the rest of the command. +- **`FrameworkExtension::getGlobals()`'s Twig `app.*` global read `$_ENV` directly instead of `config('app.*')`** + — a value set only in `config/app.php` (not mirrored to an env var) was invisible to `{{ app.name }}`/ + `{{ app.version }}` in templates even though `config('app.name')` elsewhere in the same app saw it correctly. + Now reads through `config()` like everywhere else, and `app.version`'s stale `0.1.0` fallback is gone (empty + string when unset, same as `AboutCommand`). + ## [2.4.0] - 2026-10-02 ### Added diff --git a/src/Application.php b/src/Application.php index 79c96c5..fcc65d0 100644 --- a/src/Application.php +++ b/src/Application.php @@ -240,13 +240,12 @@ private function bindCoreServices(): void ); }); - // Console Kernel + // Console Kernel — "Marrow {framework_version()}", not the app's own + // name/version: the banner is identifying the CLI tool itself (the + // same reasoning as artisan printing "Laravel Framework 11.x"), and + // `forge about` is where the app's own name/version are shown. $this->container->singleton(ConsoleKernel::class, function () { - return new ConsoleKernel( - $this->container, - $this->config->get('app.name', 'Marrow'), - $this->config->get('app.version', '0.1.0') - ); + return new ConsoleKernel($this->container, 'Marrow', framework_version()); }); // Session Manager diff --git a/src/Console/Commands/AboutCommand.php b/src/Console/Commands/AboutCommand.php index 70ed263..9d0a3e0 100644 --- a/src/Console/Commands/AboutCommand.php +++ b/src/Console/Commands/AboutCommand.php @@ -24,19 +24,21 @@ public function __construct(private readonly Container $container) protected function handle(): int { - $version = $_ENV['APP_VERSION'] ?? '0.2.0'; + $frameworkVersion = framework_version(); + $appName = (string) config('app.name', 'Marrow'); + $appVersion = (string) config('app.version', ''); $this->newLine(); - $this->output->writeln(" INFO Marrow v{$version} — Application Information"); + $this->output->writeln(" INFO {$appName} — Application Information"); $this->newLine(); // ── Environment ────────────────────────────────────────────── $this->output->writeln(' Environment'); $this->twoColumnDetail(' PHP', PHP_VERSION); - $this->twoColumnDetail(' Framework', $version); - $this->twoColumnDetail(' App', $_ENV['APP_NAME'] ?? 'Marrow'); - $this->twoColumnDetail(' Env', $_ENV['APP_ENV'] ?? 'production'); - $debug = ($_ENV['APP_DEBUG'] ?? 'false') === 'true'; + $this->twoColumnDetail(' Framework', "marrow/framework {$frameworkVersion}"); + $this->twoColumnDetail(' App', $appVersion === '' ? $appName : "{$appName} v{$appVersion}"); + $this->twoColumnDetail(' Env', (string) config('app.env', 'production')); + $debug = (bool) config('app.debug', false); $this->twoColumnDetail(' Debug', $debug ? 'enabled' : 'disabled'); $this->twoColumnDetail(' Path', base_path()); $this->newLine(); diff --git a/src/Support/helpers.php b/src/Support/helpers.php index 3a60a71..de8d45b 100644 --- a/src/Support/helpers.php +++ b/src/Support/helpers.php @@ -24,6 +24,30 @@ function config(string $key, mixed $default = null): mixed } } +if (!function_exists('framework_version')) { + /** + * The installed `marrow/framework` version (e.g. "2.4.1"), resolved from + * Composer's own runtime metadata — never hand-maintained, so it can't + * go stale the way a hardcoded constant would. Distinct from + * `app()->version()` (`config('app.version')`), which is the + * *application's* own version, not the framework's. + * + * Falls back to 'dev' outside a Composer-managed install (e.g. this + * monorepo's own core/ checkout, where 'marrow/framework' isn't a + * dependency of itself). + */ + function framework_version(): string + { + if ( + class_exists(\Composer\InstalledVersions::class) + && \Composer\InstalledVersions::isInstalled('marrow/framework') + ) { + return \Composer\InstalledVersions::getPrettyVersion('marrow/framework') ?? 'dev'; + } + return 'dev'; + } +} + if (!function_exists('env')) { function env(string $key, mixed $default = null): mixed { diff --git a/src/Template/FrameworkExtension.php b/src/Template/FrameworkExtension.php index b29d092..5f946e4 100644 --- a/src/Template/FrameworkExtension.php +++ b/src/Template/FrameworkExtension.php @@ -429,10 +429,10 @@ public function getGlobals(): array { return [ 'app' => [ - 'name' => $_ENV['APP_NAME'] ?? 'Marrow', - 'env' => $_ENV['APP_ENV'] ?? 'production', - 'debug' => (bool) ($_ENV['APP_DEBUG'] ?? false), - 'version' => $_ENV['APP_VERSION'] ?? '0.1.0', + 'name' => config('app.name', 'Marrow'), + 'env' => config('app.env', 'production'), + 'debug' => (bool) config('app.debug', false), + 'version' => config('app.version', ''), ], ]; } From 5624f828092b8e0c5cc755703f3abf3a4c465935 Mon Sep 17 00:00:00 2001 From: Aure Dulvresse <147180124+AureDulvresse@users.noreply.github.com> Date: Tue, 6 Oct 2026 12:51:21 +0100 Subject: [PATCH 2/3] Add Command::multiChoice() for interactive multi-select prompts Thin wrapper around SymfonyStyle::choice()'s multiSelect mode, following the same pattern as the existing choice()/confirm()/ask() helpers. Used by marrow/anvil's now-interactive anvil:install. Co-Authored-By: Claude Sonnet 5 --- src/Console/Command.php | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/src/Console/Command.php b/src/Console/Command.php index 3863b17..8abd0da 100644 --- a/src/Console/Command.php +++ b/src/Console/Command.php @@ -312,6 +312,20 @@ protected function choice(string $question, array $choices, mixed $default = nul return (string) $this->io->choice($question, $choices, $default); } + /** + * Same prompt as choice(), but lets the user pick any number of options + * (space to toggle, enter to confirm) instead of exactly one. + * + * @param string[] $choices + * @param string[] $default Pre-selected choices (must match $choices' values). + * @return string[] Selected choices, in the order the user picked them. + */ + protected function multiChoice(string $question, array $choices, array $default = []): array + { + $result = $this->io->choice($question, $choices, $default === [] ? null : implode(',', $default), true); + return array_values((array) $result); + } + protected function table(array $headers, array $rows): void { $this->io->table($headers, $rows); From f88d960714431411c2816685ca356423b49b1810 Mon Sep 17 00:00:00 2001 From: Aure Dulvresse <147180124+AureDulvresse@users.noreply.github.com> Date: Tue, 6 Oct 2026 13:13:20 +0100 Subject: [PATCH 3/3] BREAKING: Response composes Symfony Response instead of extending it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RedirectResponse and JsonResponse were each a sibling of Response (both extended their own Symfony ancestor directly), not a subtype of it — so a controller method returning either a redirect or a rendered view had no single Marrow type to declare, only their shared Symfony ancestor worked. marrow/warden's controllers are the clearest example of the workaround this forced. Response now holds a Symfony\...\Response internally instead of extending it; RedirectResponse/JsonResponse extend Response directly, each composing their own more specific Symfony instance. Every Symfony method not explicitly redeclared still works via __call() (documented with @mixin for IDE/PHPStan support); headers is a real public property pointing at the same ResponseHeaderBag the wrapped instance sends from, not magic. Propagated the type change through every internal consumer: Router, Pipeline, Kernel, SessionManager, all bundled middleware, and fixed ApiController's JSON helpers, which were still constructing a raw Symfony JsonResponse directly (latent bug: would have broken the moment Router::toResponse()'s now-Marrow-typed instanceof check saw one). Verified: isolated unit tests for the new Response/RedirectResponse/ JsonResponse wrapper (fluent chaining preserved on the Marrow instance, not leaked to the inner Symfony one; clone deep-copies correctly); full pest suite (261 passing) and phpstan (0 errors); and a true end-to-end pass — overlaid this code into a real skeleton app's vendor/marrow/framework, ran its Pest suite against the real Http\Kernel, and hit a real `php forge serve` instance with curl to confirm headers/cookies/status/body all still send correctly. Breaking for any app/package with its own middleware or controller typed against Symfony\Component\HttpFoundation\Response as the same workaround — see CHANGELOG.md for the fix. Co-Authored-By: Claude Sonnet 5 --- CHANGELOG.md | 30 ++++++++ docs/http.md | 14 ++++ docs/middleware.md | 2 +- src/Exceptions/Handler.php | 4 +- src/Http/ApiController.php | 1 - src/Http/JsonResponse.php | 13 +++- src/Http/Kernel.php | 3 +- src/Http/RedirectResponse.php | 30 +++++++- src/Http/Response.php | 90 ++++++++++++++++++++-- src/Middleware/Authenticate.php | 2 +- src/Middleware/HandleCors.php | 2 +- src/Middleware/HotReloadMiddleware.php | 4 +- src/Middleware/MaintenanceMode.php | 2 +- src/Middleware/Pipeline.php | 2 +- src/Middleware/RedirectIfAuthenticated.php | 2 +- src/Middleware/RequestLogger.php | 2 +- src/Middleware/SanitizeInput.php | 2 +- src/Middleware/SecurityHeaders.php | 2 +- src/Middleware/ShareErrorsFromSession.php | 2 +- src/Middleware/StartSession.php | 2 +- src/Middleware/ThrottleRequests.php | 2 +- src/Middleware/TrimStrings.php | 2 +- src/Middleware/VerifyCsrfToken.php | 2 +- src/Middleware/VerifyWebhookSignature.php | 2 +- src/Routing/Router.php | 2 +- src/Session/SessionManager.php | 2 +- tests/TestCase.php | 2 +- tests/Unit/PipelineTest.php | 2 +- tests/Unit/SecurityHeadersTest.php | 2 +- 29 files changed, 189 insertions(+), 40 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7ca4640..e9949a6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,36 @@ Les versions `0.1.x`/`0.2.0` (juin 2026) correspondent à la phase de prototypag ## [Unreleased] +## [3.0.0] - 2026-10-06 + +### Changed (breaking) + +- **`Http\Response` now *composes* a `Symfony\Component\HttpFoundation\Response` instead of extending it** — + specifically so `Http\RedirectResponse` and `Http\JsonResponse` can be genuine subclasses of `Response` + instead of independent siblings of it (each previously extended its own Symfony ancestor directly: + `RedirectResponse extends Symfony\...\RedirectResponse`, `Response extends Symfony\...\Response` — two + unrelated Symfony leaf classes, so `RedirectResponse` was never an `instanceof Response` even though both are + "a Marrow response" in every way that matters to application code). A controller method that can return + either a redirect or a rendered view now simply declares `: Response` — no more typing against + `Symfony\Component\HttpFoundation\Response` as a workaround (see `marrow/warden`'s controllers, simplified + back in the same release this motivated). + - Every Symfony Response method not explicitly redeclared (`setContent()`, `getStatusCode()`, `isRedirect()`, + cache/cookie/vary helpers, ...) still works, forwarded via `__call()` — `@mixin` on the class gives + IDEs/PHPStan full autocomplete/type-checking for those. `$response->headers` is a real public property (the + same `ResponseHeaderBag` the wrapped instance reads when sending), not magic. `toSymfonyResponse()` is the + escape hatch for anything that specifically needs the real Symfony instance. + - **Breaking for any app/package with its own middleware or controller typed against + `Symfony\Component\HttpFoundation\Response`** (the previous, now-unnecessary workaround) — a `Marrow\Http\Response` + instance is no longer an `instanceof` Symfony's Response, so PHP's own return-type enforcement will throw a + `TypeError` the first time such code returns one. Fix: change the type to `Marrow\Http\Response`. Every + framework-internal consumer (Router, Pipeline, Kernel, SessionManager, all bundled middleware) already made + this exact change — see docs/http.md. + - Also fixed one latent instance of the same root cause along the way: `ApiController`'s JSON helpers + (`ok()`, `created()`, `notFound()`, ...) constructed `Symfony\Component\HttpFoundation\JsonResponse` + directly instead of `Marrow\Http\JsonResponse` — harmless on its own, but would have broken the moment one + of those methods' return value reached `Router::toResponse()`'s now-`Marrow\Http\Response`-typed + `instanceof` check. + ## [2.5.0] - 2026-10-06 ### Added diff --git a/docs/http.md b/docs/http.md index 436a4ab..79cfa62 100644 --- a/docs/http.md +++ b/docs/http.md @@ -38,6 +38,20 @@ contexts without dependency injection (closures, one-off CLI output) — from inside a `Controller`, prefer the instance helpers below, which also carry the current request/router context. +`JsonResponse` and `RedirectResponse` are genuine subclasses of `Response` — +a method that can return either a redirect or a rendered view only ever +needs to declare `: Response`, nothing broader. Internally, `Response` +*composes* a `Symfony\Component\HttpFoundation\Response` rather than +extending it (so `RedirectResponse`/`JsonResponse` can extend *this* class +instead of their own separate Symfony ancestors, which is what made them +siblings of `Response`, not subtypes, in the past). Every Symfony Response +method not explicitly redeclared — `setContent()`, `getStatusCode()`, +`isRedirect()`, cache/cookie/vary helpers, all of it — still works via +`__call()` forwarding; `$response->headers` is a real property (the same +`ResponseHeaderBag` the wrapped instance reads when sending), not magic. +`$response->toSymfonyResponse()` is the escape hatch for anything that +specifically needs the real Symfony instance. + ## `Controller` helpers Every controller extending `Http\Controller` gets `TemplateEngine`, diff --git a/docs/middleware.md b/docs/middleware.md index f8bd070..eb2ddc6 100644 --- a/docs/middleware.md +++ b/docs/middleware.md @@ -132,7 +132,7 @@ $router->get('/admin', Controller::class . '@index')->middleware(['web', 'auth'] namespace App\Middleware; use Marrow\Http\Request; -use Symfony\Component\HttpFoundation\Response; +use Marrow\Http\Response; class EnsureAdmin { diff --git a/src/Exceptions/Handler.php b/src/Exceptions/Handler.php index 677c1a1..8de7ddf 100644 --- a/src/Exceptions/Handler.php +++ b/src/Exceptions/Handler.php @@ -30,7 +30,7 @@ public function __construct( ) { } - public function render(Request $request, Throwable $e): Response|JsonResponse + public function render(Request $request, Throwable $e): Response { if ($e instanceof ValidationException) { return $this->renderValidationError($request, $e); @@ -63,7 +63,7 @@ public function render(Request $request, Throwable $e): Response|JsonResponse // ── Validation ─────────────────────────────────────────────────── - private function renderValidationError(Request $request, ValidationException $e): Response|JsonResponse + private function renderValidationError(Request $request, ValidationException $e): Response { if ($request->wantsJson()) { return new JsonResponse( diff --git a/src/Http/ApiController.php b/src/Http/ApiController.php index fd6c84e..73d20c9 100644 --- a/src/Http/ApiController.php +++ b/src/Http/ApiController.php @@ -6,7 +6,6 @@ use Marrow\Http\Resources\JsonResource; use Marrow\Http\Resources\ResourceCollection; -use Symfony\Component\HttpFoundation\JsonResponse; /** * Base controller for API endpoints. diff --git a/src/Http/JsonResponse.php b/src/Http/JsonResponse.php index a01c901..f9323d8 100644 --- a/src/Http/JsonResponse.php +++ b/src/Http/JsonResponse.php @@ -7,13 +7,20 @@ use Symfony\Component\HttpFoundation\JsonResponse as SymfonyJsonResponse; /** - * JSON response wrapper (thin layer over Symfony JsonResponse). + * JSON response — a genuine subclass of `Response` (see its docblock), + * composing a `Symfony\Component\HttpFoundation\JsonResponse` internally + * instead of `Response`'s plain base one, so `setData()`/`getData()`/ + * `setCallback()`/... all still work via the inherited `__call()` + * forwarding, with no need to redeclare them here. + * + * @mixin SymfonyJsonResponse */ -class JsonResponse extends SymfonyJsonResponse +class JsonResponse extends Response { public function __construct(mixed $data = null, int $status = 200, array $headers = []) { - parent::__construct($data, $status, $headers); + $this->response = new SymfonyJsonResponse($data, $status, $headers); + $this->headers = $this->response->headers; $this->headers->set('Content-Type', 'application/json'); } } diff --git a/src/Http/Kernel.php b/src/Http/Kernel.php index cf1c37e..d286b15 100644 --- a/src/Http/Kernel.php +++ b/src/Http/Kernel.php @@ -11,7 +11,6 @@ use Marrow\Middleware\Pipeline; use Marrow\Routing\Router; use Marrow\Session\SessionManager; -use Symfony\Component\HttpFoundation\Response as SymfonyResponse; use Throwable; /** @@ -28,7 +27,7 @@ public function __construct( ) { } - public function handle(Request $request): SymfonyResponse + public function handle(Request $request): Response { try { // Bind the active request into the container so request-scoped diff --git a/src/Http/RedirectResponse.php b/src/Http/RedirectResponse.php index cb848db..8e55d4d 100644 --- a/src/Http/RedirectResponse.php +++ b/src/Http/RedirectResponse.php @@ -10,21 +10,45 @@ /** * Redirect response with named-route support and back() helper. * + * A genuine subclass of `Response` (not a Symfony-RedirectResponse sibling + * of it — see Response's own docblock) — composes a + * `Symfony\Component\HttpFoundation\RedirectResponse` internally instead of + * `Response`'s plain base one, so `getTargetUrl()`/`setTargetUrl()` (and + * everything else specific to a redirect) still work via the inherited + * `__call()` forwarding, with no need to redeclare them here. + * * Inside a Controller, prefer `$this->redirectToRoute(...)` — it uses the * Controller's own constructor-injected Router. route() below resolves the * Router ambiently via Application::getInstance() and exists for contexts * with no DI available at all. + * + * @mixin SymfonyRedirect */ -class RedirectResponse extends SymfonyRedirect +class RedirectResponse extends Response { private array $flashData = []; public function __construct(string $url = '', int $status = 302, array $headers = []) { - parent::__construct($url ?: '/', $status, $headers); + // Deliberately not parent::__construct() — that builds a plain + // Symfony Response, not a Symfony RedirectResponse. + $this->response = new SymfonyRedirect($url ?: '/', $status, $headers); + $this->headers = $this->response->headers; } - /** Redirect to a named route. */ + /** + * Redirect to a named route. + * + * setTargetUrl() goes through $this (not $this->response) deliberately: + * the inherited $response property stays typed as the *base* Symfony + * Response (PHP property types are invariant — a subclass cannot + * re-type an inherited property, even to a covariant one, which a + * SymfonyRedirect-typed override here would be), so calling + * setTargetUrl() on it directly wouldn't type-check. Routing through + * $this uses Response's inherited __call() forwarding instead, which is + * exactly what @mixin SymfonyRedirect above documents as the escape + * hatch for everything redirect-specific this class doesn't redeclare. + */ public function route(string $name, array $params = []): static { $url = Application::getInstance()->getContainer()->make(\Marrow\Routing\Router::class)->route($name, $params); diff --git a/src/Http/Response.php b/src/Http/Response.php index 586c027..4b35bc5 100644 --- a/src/Http/Response.php +++ b/src/Http/Response.php @@ -6,17 +6,93 @@ use Marrow\Application; use Symfony\Component\HttpFoundation\Response as SymfonyResponse; +use Symfony\Component\HttpFoundation\ResponseHeaderBag; /** - * HTTP Response wrapper with convenient static factories. + * HTTP Response — composes a Symfony Response internally instead of + * extending it, specifically so `Marrow\Http\RedirectResponse` and + * `Marrow\Http\JsonResponse` can be genuine *subclasses of this class* + * rather than siblings of it. * - * Inside a Controller, prefer the injected instance helpers (`$this->view(...)`) - * — they use the Controller's own constructor-injected TemplateEngine. These - * static factories resolve services ambiently via Application::getInstance() - * and exist for contexts with no DI available at all (closures, CLI output). + * Before this: `Marrow\Http\Response extends Symfony\...\Response` and + * `Marrow\Http\RedirectResponse extends Symfony\...\RedirectResponse` + * directly — two different Symfony leaf classes, meaning + * `RedirectResponse` was never an `instanceof Response` even though both + * were "a Marrow response" in every way that mattered to application code. + * A controller method that could return either a rendered view and a + * redirect had no single Marrow type to declare — only their shared + * Symfony ancestor worked, forcing every such method (and this framework's + * own Router/Pipeline internals) to type against + * `Symfony\Component\HttpFoundation\Response` instead of this class. See + * `marrow/warden`'s controllers (pre-this-change) for the exact workaround. + * + * Every Symfony Response method not explicitly overridden below (e.g. + * `setContent()`, `getStatusCode()`, `isRedirect()`, ...) still works via + * `__call()`, forwarded to the wrapped instance — `@mixin` below is what + * gives IDEs/PHPStan full autocomplete and type-checking for those without + * hand-writing a forwarding method for each one. `headers` is a real public + * property (not magic), assigned once to the *same* `ResponseHeaderBag` + * object the wrapped response reads from when sending — mutating it here + * mutates what actually gets sent, with no extra plumbing needed. + * + * @mixin SymfonyResponse */ -class Response extends SymfonyResponse +class Response { + public ResponseHeaderBag $headers; + + protected SymfonyResponse $response; + + public function __construct(?string $content = '', int $status = 200, array $headers = []) + { + $this->response = new SymfonyResponse($content, $status, $headers); + $this->headers = $this->response->headers; + } + + /** The real Symfony Response underneath — escape hatch for anything genuinely Symfony-specific. */ + public function toSymfonyResponse(): SymfonyResponse + { + return $this->response; + } + + /** + * Forwards to the wrapped Symfony Response. A fluent method there + * (`setStatusCode()`, `setContent()`, ...) returns that instance itself + * for chaining — substituted with `$this` here so chaining continues on + * *this* wrapper, not a leaked reference to the inner Symfony object. + */ + public function __call(string $name, array $arguments): mixed + { + $result = $this->response->{$name}(...$arguments); + return $result === $this->response ? $this : $result; + } + + public function __toString(): string + { + return (string) $this->response; + } + + /** Deep-clones the wrapped response too, so `headers` stays in sync with it rather than aliasing the original's. */ + public function __clone(): void + { + $this->response = clone $this->response; + $this->headers = $this->response->headers; + } + + public function send(bool $flush = true): static + { + $this->response->send($flush); + return $this; + } + + public function prepare(Request $request): static + { + $this->response->prepare($request); + return $this; + } + + // ── Static factories (unchanged from before this class composed Symfony) ── + /** Render a Twig template and return a Response. */ public static function view(string $template, array $data = [], int $status = 200): self { @@ -37,7 +113,7 @@ public static function redirect(string $url = '', int $status = 302): RedirectRe return new RedirectResponse($url, $status); } - public static function render(string $template, array $data = [], int $status = 200, array $headers = []): SymfonyResponse + public static function render(string $template, array $data = [], int $status = 200, array $headers = []): self { $engine = Application::getInstance()->getContainer()->make(\Marrow\Template\Engine::class); $html = $engine->render($template, $data); diff --git a/src/Middleware/Authenticate.php b/src/Middleware/Authenticate.php index 421f6f4..7f03ffc 100644 --- a/src/Middleware/Authenticate.php +++ b/src/Middleware/Authenticate.php @@ -9,7 +9,7 @@ use Marrow\Exceptions\HttpException; use Marrow\Http\Request; use Marrow\Http\RedirectResponse; -use Symfony\Component\HttpFoundation\Response; +use Marrow\Http\Response; /** * Rejects the request unless the given guard (default: `session`) has an diff --git a/src/Middleware/HandleCors.php b/src/Middleware/HandleCors.php index 68c4716..8340f6c 100644 --- a/src/Middleware/HandleCors.php +++ b/src/Middleware/HandleCors.php @@ -6,7 +6,7 @@ use Marrow\Config\Repository as Config; use Marrow\Http\Request; -use Symfony\Component\HttpFoundation\Response; +use Marrow\Http\Response; /** * CORS middleware — adds Access-Control-* headers to every response. diff --git a/src/Middleware/HotReloadMiddleware.php b/src/Middleware/HotReloadMiddleware.php index 8a8c1fb..4df7d0c 100644 --- a/src/Middleware/HotReloadMiddleware.php +++ b/src/Middleware/HotReloadMiddleware.php @@ -5,9 +5,9 @@ namespace Marrow\Middleware; use Marrow\Application; +use Marrow\Http\JsonResponse; use Marrow\Http\Request; -use Symfony\Component\HttpFoundation\JsonResponse; -use Symfony\Component\HttpFoundation\Response; +use Marrow\Http\Response; /** * Auto-refreshes the browser during local development, for a project that diff --git a/src/Middleware/MaintenanceMode.php b/src/Middleware/MaintenanceMode.php index 538099f..bc10f43 100644 --- a/src/Middleware/MaintenanceMode.php +++ b/src/Middleware/MaintenanceMode.php @@ -7,7 +7,7 @@ use Marrow\Application; use Marrow\Exceptions\HttpException; use Marrow\Http\Request; -use Symfony\Component\HttpFoundation\Response; +use Marrow\Http\Response; /** * Blocks every request with a 503 while storage/maintenance.flag exists diff --git a/src/Middleware/Pipeline.php b/src/Middleware/Pipeline.php index 96ff5c4..718ce16 100644 --- a/src/Middleware/Pipeline.php +++ b/src/Middleware/Pipeline.php @@ -7,7 +7,7 @@ use Closure; use Marrow\Container; use Marrow\Http\Request; -use Symfony\Component\HttpFoundation\Response; +use Marrow\Http\Response; /** * Middleware pipeline — sends a Request through an ordered stack of middlewares diff --git a/src/Middleware/RedirectIfAuthenticated.php b/src/Middleware/RedirectIfAuthenticated.php index c352e74..bdc2862 100644 --- a/src/Middleware/RedirectIfAuthenticated.php +++ b/src/Middleware/RedirectIfAuthenticated.php @@ -7,7 +7,7 @@ use Marrow\Auth\AuthManager; use Marrow\Http\Request; use Marrow\Http\RedirectResponse; -use Symfony\Component\HttpFoundation\Response; +use Marrow\Http\Response; /** * Inverse of Authenticate: redirects to `/` if the given guard (default: diff --git a/src/Middleware/RequestLogger.php b/src/Middleware/RequestLogger.php index 0169d40..af1dbc5 100644 --- a/src/Middleware/RequestLogger.php +++ b/src/Middleware/RequestLogger.php @@ -7,7 +7,7 @@ use Marrow\Database\Connection; use Marrow\Http\Request; use Psr\Log\LoggerInterface; -use Symfony\Component\HttpFoundation\Response; +use Marrow\Http\Response; /** * Logs every HTTP request (method, URI, status, duration) via PSR-3. diff --git a/src/Middleware/SanitizeInput.php b/src/Middleware/SanitizeInput.php index 8e701aa..fac0b33 100644 --- a/src/Middleware/SanitizeInput.php +++ b/src/Middleware/SanitizeInput.php @@ -5,7 +5,7 @@ namespace Marrow\Middleware; use Marrow\Http\Request; -use Symfony\Component\HttpFoundation\Response; +use Marrow\Http\Response; /** * SanitizeInput — strips null bytes and enforces string length limits. diff --git a/src/Middleware/SecurityHeaders.php b/src/Middleware/SecurityHeaders.php index 88c9333..be43f04 100644 --- a/src/Middleware/SecurityHeaders.php +++ b/src/Middleware/SecurityHeaders.php @@ -7,7 +7,7 @@ use Marrow\Http\ContentSecurityPolicy; use Marrow\Http\Request; use Marrow\Http\Shield\ShieldConfig; -use Symfony\Component\HttpFoundation\Response; +use Marrow\Http\Response; /** * Adds common security headers to every response — part of Marrow's Shield diff --git a/src/Middleware/ShareErrorsFromSession.php b/src/Middleware/ShareErrorsFromSession.php index b6ebd27..b5d1090 100644 --- a/src/Middleware/ShareErrorsFromSession.php +++ b/src/Middleware/ShareErrorsFromSession.php @@ -7,7 +7,7 @@ use Marrow\Http\Request; use Marrow\Session\SessionManager; use Marrow\Template\Engine; -use Symfony\Component\HttpFoundation\Response; +use Marrow\Http\Response; /** * Pulls the `_errors`/`_old_input` flashed by a failed validation redirect diff --git a/src/Middleware/StartSession.php b/src/Middleware/StartSession.php index 8b1cc0a..3a40935 100644 --- a/src/Middleware/StartSession.php +++ b/src/Middleware/StartSession.php @@ -7,7 +7,7 @@ use Marrow\Http\Request; use Marrow\Http\RedirectResponse; use Marrow\Session\SessionManager; -use Symfony\Component\HttpFoundation\Response; +use Marrow\Http\Response; /** * Starts the session before the request is handled, flashes any pending diff --git a/src/Middleware/ThrottleRequests.php b/src/Middleware/ThrottleRequests.php index 6308fb0..2d971df 100644 --- a/src/Middleware/ThrottleRequests.php +++ b/src/Middleware/ThrottleRequests.php @@ -7,7 +7,7 @@ use Marrow\Exceptions\HttpException; use Marrow\Http\Request; use Marrow\RateLimiting\RateLimiter; -use Symfony\Component\HttpFoundation\Response; +use Marrow\Http\Response; /** * Sliding-window request throttling. diff --git a/src/Middleware/TrimStrings.php b/src/Middleware/TrimStrings.php index 4bca997..5ff170d 100644 --- a/src/Middleware/TrimStrings.php +++ b/src/Middleware/TrimStrings.php @@ -5,7 +5,7 @@ namespace Marrow\Middleware; use Marrow\Http\Request; -use Symfony\Component\HttpFoundation\Response; +use Marrow\Http\Response; /** * Trims leading/trailing whitespace from every string input field, except diff --git a/src/Middleware/VerifyCsrfToken.php b/src/Middleware/VerifyCsrfToken.php index 12ff081..cd84342 100644 --- a/src/Middleware/VerifyCsrfToken.php +++ b/src/Middleware/VerifyCsrfToken.php @@ -8,7 +8,7 @@ use Marrow\Http\Request; use Marrow\Http\Shield\ShieldConfig; use Marrow\Session\SessionManager; -use Symfony\Component\HttpFoundation\Response; +use Marrow\Http\Response; /** * Verifies the CSRF token on state-changing requests — part of Marrow's diff --git a/src/Middleware/VerifyWebhookSignature.php b/src/Middleware/VerifyWebhookSignature.php index d058530..d12d900 100644 --- a/src/Middleware/VerifyWebhookSignature.php +++ b/src/Middleware/VerifyWebhookSignature.php @@ -8,7 +8,7 @@ use Marrow\Exceptions\HttpException; use Marrow\Http\Request; use Marrow\Http\Webhook\WebhookSignature; -use Symfony\Component\HttpFoundation\Response; +use Marrow\Http\Response; /** * Rejects an inbound webhook request unless it carries a valid HMAC diff --git a/src/Routing/Router.php b/src/Routing/Router.php index 9ecfea0..33be0ed 100644 --- a/src/Routing/Router.php +++ b/src/Routing/Router.php @@ -15,7 +15,7 @@ use ReflectionMethod; use ReflectionNamedType; use ReflectionParameter; -use Symfony\Component\HttpFoundation\Response; +use Marrow\Http\Response; /** * Fluent HTTP router with groups, resource routes, named routes, diff --git a/src/Session/SessionManager.php b/src/Session/SessionManager.php index 6425393..578e9a6 100644 --- a/src/Session/SessionManager.php +++ b/src/Session/SessionManager.php @@ -5,8 +5,8 @@ namespace Marrow\Session; use Marrow\Http\Request; +use Marrow\Http\Response; use Symfony\Component\HttpFoundation\Cookie; -use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Session\Session; use Symfony\Component\HttpFoundation\Session\Storage\NativeSessionStorage; diff --git a/tests/TestCase.php b/tests/TestCase.php index 4f6b0af..399bc34 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -6,7 +6,7 @@ use Marrow\Http\Request as MarrowRequest; use PHPUnit\Framework\TestCase as PhpUnitTestCase; -use Symfony\Component\HttpFoundation\Response; +use Marrow\Http\Response; /** * Base TestCase for the Marrow framework test suite. diff --git a/tests/Unit/PipelineTest.php b/tests/Unit/PipelineTest.php index f0c5972..343efcd 100644 --- a/tests/Unit/PipelineTest.php +++ b/tests/Unit/PipelineTest.php @@ -7,7 +7,7 @@ use Marrow\Container; use Marrow\Http\Request; use Marrow\Middleware\Pipeline; -use Symfony\Component\HttpFoundation\Response; +use Marrow\Http\Response; // ── Tests ───────────────────────────────────────────────────────────────────── // diff --git a/tests/Unit/SecurityHeadersTest.php b/tests/Unit/SecurityHeadersTest.php index 881bad9..6c00eac 100644 --- a/tests/Unit/SecurityHeadersTest.php +++ b/tests/Unit/SecurityHeadersTest.php @@ -7,7 +7,7 @@ use Marrow\Http\Request; use Marrow\Http\Shield\ShieldConfig; use Marrow\Middleware\SecurityHeaders; -use Symfony\Component\HttpFoundation\Response; +use Marrow\Http\Response; // ── Tests ─────────────────────────────────────────────────────────────────────