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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 56 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,62 @@ 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

- **`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
Expand Down
14 changes: 14 additions & 0 deletions docs/http.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`,
Expand Down
2 changes: 1 addition & 1 deletion docs/middleware.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand Down
11 changes: 5 additions & 6 deletions src/Application.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
14 changes: 14 additions & 0 deletions src/Console/Command.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
14 changes: 8 additions & 6 deletions src/Console/Commands/AboutCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -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(" <options=bold;fg=blue>INFO</> Marrow <options=bold>v{$version}</> — Application Information");
$this->output->writeln(" <options=bold;fg=blue>INFO</> {$appName} — Application Information");
$this->newLine();

// ── Environment ──────────────────────────────────────────────
$this->output->writeln(' <options=bold>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 ? '<fg=yellow>enabled</>' : '<fg=gray>disabled</>');
$this->twoColumnDetail(' Path', base_path());
$this->newLine();
Expand Down
4 changes: 2 additions & 2 deletions src/Exceptions/Handler.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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(
Expand Down
1 change: 0 additions & 1 deletion src/Http/ApiController.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@

use Marrow\Http\Resources\JsonResource;
use Marrow\Http\Resources\ResourceCollection;
use Symfony\Component\HttpFoundation\JsonResponse;

/**
* Base controller for API endpoints.
Expand Down
13 changes: 10 additions & 3 deletions src/Http/JsonResponse.php
Original file line number Diff line number Diff line change
Expand Up @@ -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');
}
}
3 changes: 1 addition & 2 deletions src/Http/Kernel.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;

/**
Expand All @@ -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
Expand Down
30 changes: 27 additions & 3 deletions src/Http/RedirectResponse.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
90 changes: 83 additions & 7 deletions src/Http/Response.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand All @@ -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);
Expand Down
2 changes: 1 addition & 1 deletion src/Middleware/Authenticate.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading