diff --git a/versions/4.x/authentication.md b/versions/4.x/authentication.md index f06d112..e82d048 100644 --- a/versions/4.x/authentication.md +++ b/versions/4.x/authentication.md @@ -3,7 +3,7 @@ title: Authentications description: Doppar authentications page meta: - name: keywords - content: authentications + content: authentications, logout other devices, browser sessions, auth.session middleware, remember me, two-factor authentication --- ## Authentication @@ -175,6 +175,113 @@ class LoginController extends Controller } ``` +## Logging Out Other Devices +A user who suspects their account is in use somewhere else, or who just changed their password, can sign out of every other browser and device while staying signed in on the current one. Ask for their current password to confirm, then call `logoutOtherDevices()`: + +```php +password)) { + return back()->withErrors(['password' => 'The password is incorrect.']); + } + + return back()->with('status', 'You have been signed out of your other devices.'); + } +} +``` + +`logoutOtherDevices()` returns `false` when no user is signed in or the password is wrong, and changes nothing in that case. Otherwise it: + +- **Re-hashes the password.** The password itself does not change, but every other session was signed in with the old hash, so it is no longer accepted. +- **Replaces the remember-me token.** Other devices cannot sign back in from their remember-me cookie. If the current device was remembered, it gets a new cookie and stays remembered. +- **Keeps the current device signed in.** +- **Deletes the other sessions immediately** when you use the [database session driver](/versions/4.x/session#database-driver), which records the user of each session. + +### Checking Sessions on Every Request +With the `file` and `cookie` drivers, the other devices' sessions still exist after `logoutOtherDevices()`. They are signed out the next time they make a request, by the `AuthenticateSession` middleware. Register it in the `web` middleware group of `src/Http/Gateway.php`, so it runs before `auth`: + +```php +public $middlewareGroups = [ + 'web' => [ + \Phaseolies\Middleware\CsrfTokenMiddleware::class, + \Phaseolies\Middleware\AuthenticateSession::class, + ], + 'api' => [], +]; +``` + +The middleware compares the password the session was signed in with to the user's current password. A session that no longer matches is signed out, and the request carries on as a guest, so the `auth` middleware of a protected route sends it to the login page. + +This also signs a user out of their other devices whenever their password changes, for example after a password reset. The device that changed the password stays signed in, as long as it changed the password of the signed-in user (`auth()->user()`). If you change it on another instance of the model, keep the current device signed in with: + +```php +auth()->refreshSessionFingerprint(); +``` + +> **Note:** The middleware is optional with the `database` driver, which deletes the other sessions immediately. Registering it anyway also covers password changes made outside `logoutOtherDevices()`. + +Sessions created before the middleware was registered are accepted once and then checked from their next request on, so registering it does not sign anyone out. + +### Listing a User's Devices +With the `database` driver, `sessions()` returns the devices the signed-in user is currently signed in on, most recently active first: + +```php +$sessions = auth()->sessions(); + +// [ +// ['ip_address' => '203.0.113.9', 'user_agent' => 'Mozilla/5.0 ...', 'last_activity' => 1730700000, 'is_current' => true], +// ['ip_address' => '198.51.100.4', 'user_agent' => 'Mozilla/5.0 ...', 'last_activity' => 1730690000, 'is_current' => false], +// ] +``` + +Session IDs are never returned, because a session ID is enough to act as the user. With other drivers, `sessions()` returns an empty array. + +```html +#foreach (auth()->sessions() as $session) +
  • + [[ $session['user_agent'] ?? 'Unknown device' ]] · [[ $session['ip_address'] ]] + #if ($session['is_current']) (this device) #endif +
  • +#endforeach +``` + +### Other Actors +The database driver records the user of the **default** actor only. For any other actor, `logoutOtherDevices()` still re-hashes the password and replaces the remember-me token, and the `AuthenticateSession` middleware signs the other devices out, but no session is deleted immediately and `sessions()` is empty. Apply the middleware to such an actor's routes with its name: + +```php +#[Middleware(['auth.session:admin'])] +``` + +after registering the alias in `src/Http/Gateway.php`: + +```php +'auth.session' => \Phaseolies\Middleware\AuthenticateSession::class, +``` + +### Custom Password and Remember Token Columns +`logoutOtherDevices()` writes the password and remember token columns directly, so it works even when they are not in the model's `$creatable` list, and model hooks do not run. If your users table names them differently, override these methods on your model: + +```php +public function getAuthPasswordName(): string +{ + return 'password_hash'; +} + +public function getRememberTokenName(): string +{ + return 'remember_me_token'; +} +``` + ## Get Authenticated User Data To get the current authenticated user data, Doppar has `Auth::user()` method and `auth()` helper. Simply call @@ -322,6 +429,7 @@ Each actor writes its state to separate, namespaced session keys and cookies so | Remember-me cookie | `remember_doppar_web_` | `remember_doppar_admin_` | | 2FA pending user | `2fa_web_user_id` | `2fa_admin_user_id` | | 2FA remember flag | `2fa_web_remember` | `2fa_admin_remember` | +| Password fingerprint | `password_hash_web` | `password_hash_admin` | ### Logging Out of a Specific Actor Log out of the admin actor only — web actor session is untouched diff --git a/versions/4.x/csrf-protection.md b/versions/4.x/csrf-protection.md index 97929c1..46ee822 100644 --- a/versions/4.x/csrf-protection.md +++ b/versions/4.x/csrf-protection.md @@ -90,7 +90,7 @@ A front end that never loads such a page therefore has no cookie on its first re Requests that are [stateless](/versions/4.x/session#stateless-routes) have no stored session to tie a token to, so Doppar does not issue the `XSRF-TOKEN` cookie for them and they cannot pass CSRF verification. Keep token authenticated endpoints, such as a JSON API, out of the `web` middleware group, or list them in `setRelaxablePaths` as shown below. ## Ignoring CSRF Protection -To exclude certain routes from CSRF token verification (useful for APIs, webhooks, or external integrations), update the `setRelaxablePaths` array in the `src/runtime/app.php` file. +To exclude certain routes from CSRF token verification (useful for APIs, webhooks, or external integrations), update the `setRelaxablePaths` array in the `src/runtime/app.php` file. Patterns ignore case, the leading slash is optional, and `*` matches anything, exactly as they do for the [stateless](/versions/4.x/session#stateless-routes) paths of the session. Locate the section where the application is being built and modify it like so: diff --git a/versions/4.x/requests.md b/versions/4.x/requests.md index 48c7534..3a8001d 100644 --- a/versions/4.x/requests.md +++ b/versions/4.x/requests.md @@ -561,6 +561,13 @@ Equivalent to the above, using the `input()` method $request->input('name', 'default'); ``` +Called without a key, `input()` returns the complete input array, the same as `all()`: +```php +$request->input(); +``` + +`input()` and `has()` always read the current request data, so values you change with `merge()` or directly on the `$request->request` bag are visible right away. + `get()` is a lower-level lookup. It checks route parameters first, then the query string, then the request body, and returns the first match. Prefer `input()` when you only want the request input. ```php $request->get('name', 'default'); @@ -731,6 +738,8 @@ $request->host(); $request->scheme(); ``` +`scheme()` returns the same value as `getScheme()`. Behind a [trusted proxy](/versions/4.x/trust-proxies), it honors the `X-Forwarded-Proto` header, so a request that reaches the proxy over HTTPS reports `https` even if the proxy forwards it to your application over plain HTTP. + ## Retrieving the Request Method The `method()` method returns the HTTP verb in lowercase, while `getMethod` returns it in uppercase. You may use the `isGet`, `isPost`, `isPut`, `isPatch`, `isDelete` and `isHead` methods to verify that the HTTP verb matches: @@ -782,19 +791,22 @@ $token = $request->bearerToken(); ## Request IP Address -The `ip` method may be used to retrieve the IP address of the client that made the request to your application +The `ip` method may be used to retrieve the IP address of the client that made the request to your application. It is an alias of `getClientIp()`. ```php $ipAddress = $request->ip(); ``` -To retrieve an array of IP addresses — including all client IPs forwarded by proxies — you can use the `ips()` method on the `Phaseolies\Http\Request` instance. This method returns all available IPs in order, with the original client IP listed at the end of the array. +To retrieve an array of IP addresses — including all client IPs forwarded by proxies — you can use the `ips()` method on the `Phaseolies\Http\Request` instance. It is equivalent to `getClientIps()`: trusted proxy addresses are removed, and the list is ordered from most to least trusted. The first element is the closest address that isn't a trusted proxy (the value `ip()` returns), and the last is the originating client as reported by the forwarding headers. ```php -$ipAddresses = $request->ips(); +// Behind a trusted proxy, with X-Forwarded-For: 203.0.113.1, 198.51.100.2 +$ipAddresses = $request->ips(); // ["198.51.100.2", "203.0.113.1"] ``` -This is especially useful when your application is behind a load balancer or proxy and you want to trace the full route of the request +Forwarding headers such as `X-Forwarded-For` and `Forwarded` are only read when the request comes from a [trusted proxy](/versions/4.x/trust-proxies). For any other request, both methods return the connecting address (`REMOTE_ADDR`), so clients cannot spoof their IP by sending these headers themselves. + +If a trusted proxy sends a `Forwarded` and an `X-Forwarded-*` header that disagree, the request is rejected with a `400 Bad Request` (`ConflictingHeadersException`). Configure your proxy to send only one of them, or trust only the one it sets. ## Content Negotiation diff --git a/versions/4.x/responses.md b/versions/4.x/responses.md index 3b0e231..3496150 100644 --- a/versions/4.x/responses.md +++ b/versions/4.x/responses.md @@ -100,6 +100,17 @@ Response: } ``` +The same rules apply whether you return the value from a route or pass it to `response()`: + +| Value | Sent as | +| --- | --- | +| Array, `stdClass`, `ArrayObject` | JSON | +| Entity model, collection, query builder | JSON (a query builder is executed first) | +| Object implementing `JsonSerializable` | JSON, even if it also implements `Stringable` | +| Object implementing only `Stringable` | Its string value, as a normal response | +| Any other object | JSON (via `toArray()` when the object has one) | +| An existing `Response` instance | The same instance. `response($response, 503, ['Retry-After' => '10'])` applies the status and headers you pass. If you pass no status (or `200`), the response keeps its own | + #### Returning Collections Doppar's collections can be returned directly from routes. They're automatically converted to JSON: @@ -328,6 +339,9 @@ response()->redirect('/new-location', 301); // 302 Found (Temporary redirect) response()->redirect('/temporary-location', 302); +// response()->redirect() returns a RedirectResponse, so you can chain flash data +response()->redirect('/dashboard')->with('success', 'Saved!'); + // 304 Not Modified (for caching) response()->setNotModified(); ``` @@ -847,6 +861,8 @@ public function streamReportDownload() Streaming data to the client as it is generated can greatly reduce memory usage and enhance performance, particularly for extremely large responses. +The stream callback is not run for `HEAD` requests or `204`/`304` responses, since those must not have a body. A streamed response has no buffered body, so calling `setBody()` with a string on it throws a `LogicException`. The same applies to file responses. + #### Basic Streaming Stream content to the client as it's generated to reduce memory usage: diff --git a/versions/4.x/routing.md b/versions/4.x/routing.md index eb146f2..f84e16d 100644 --- a/versions/4.x/routing.md +++ b/versions/4.x/routing.md @@ -29,6 +29,34 @@ Doppar supports the following HTTP methods for defining routes: | **HEAD** | Same as GET but returns only headers. Useful for checking existence or metadata. | | **OPTIONS** | Describes available communication options. Often used for CORS preflight checks. | | +### HEAD Requests + +You don't need to register HEAD routes. A `HEAD` request is answered by the `GET` route for the same path, with the same middleware, so a protected page still redirects or rejects as it would for `GET`. The response contains only the headers. No body is sent, and a streamed response's callback is not run. + +If you register an explicit `HEAD` route, it takes precedence over the `GET` route for that path, along with its own middleware: + +```php +use Phaseolies\Support\Router\Attributes\Route; + +class ReportController extends Controller +{ + #[Route(uri: '/report')] + public function show() + { + // ... + } + + // Optional: a cheaper handler for HEAD requests to the same path + #[Route(uri: '/report', methods: ['HEAD'])] + public function headers() + { + // ... + } +} +``` + +`HEAD` never falls back to `POST`, `PUT`, `PATCH` or `DELETE` routes. + ## Route Caching Doppar supports route caching for improved performance in production environments. Route caching is controlled by the `APP_ROUTE_CACHE` environment variable: Set to true to enable route caching (recommended for production). Set to false to disable (default for development) Route cache files are stored in: `storage/framework/cache/`. ### Cache Routes diff --git a/versions/4.x/session.md b/versions/4.x/session.md index ea3a72d..ac3db54 100644 --- a/versions/4.x/session.md +++ b/versions/4.x/session.md @@ -3,7 +3,7 @@ title: Session description: Doppar session page meta: - name: keywords - content: session, session driver, file driver, cookie driver, stateless, session lifetime, session regenerate, garbage collection, custom session driver + content: session, session driver, file driver, cookie driver, database driver, sessions table, stateless, session lifetime, session regenerate, garbage collection, custom session driver --- ## HTTP Session @@ -23,7 +23,7 @@ $name = $request->session()->get('name'); ### Why Doppar Sessions - *Lazy* → nothing is created until the session is used, and an empty session is never saved -- *Driver based* → the same API works with the `file`, `cookie`, and `array` drivers, or your own +- *Driver based* → the same API works with the `file`, `cookie`, `database`, and `array` drivers, or your own - *Non-blocking* → concurrent requests from the same browser never wait for each other - *Self-cleaning* → expired sessions are removed automatically, without a background job - *Stateless when you need it* → API routes can opt out of sessions entirely @@ -34,10 +34,12 @@ The session configuration file is located at `runtime/config/session.php`. Most | Option | Environment key | Default | Description | |--------|-----------------|---------|-------------| -| `driver` | `SESSION_DRIVER` | `file` | Where session data is stored: `file`, `cookie`, or `array` | +| `driver` | `SESSION_DRIVER` | `file` | Where session data is stored: `file`, `cookie`, `database`, or `array` | | `lifetime` | `SESSION_LIFETIME` | `120` | Minutes a session stays valid without activity | | `expire_on_close` | `SESSION_EXPIRE_ON_CLOSE` | `false` | End the session when the browser is closed | | `files` | | `storage/framework/sessions` | Directory used by the `file` driver | +| `connection` | | `null` | Database connection used by the `database` driver. `null` uses the default connection | +| `table` | | `sessions` | Table used by the `database` driver | | `cookie` | `SESSION_COOKIE` | `{app name}_session` | Name of the session cookie. Characters other than letters, digits, `-`, and `_` are replaced with `_` | | `path` | `SESSION_PATH` | `/` | URL path the cookie is valid for | | `domain` | `SESSION_DOMAIN` | `null` | Domain, and subdomains, the cookie is valid for | @@ -53,7 +55,7 @@ SESSION_DRIVER = "file" SESSION_LIFETIME = 120 ``` -> **Note:** `stateless`, `lottery`, and `always_send_xsrf_cookie` are optional. A configuration file created before these options existed keeps working with the defaults shown above. +> **Note:** `stateless`, `lottery`, `always_send_xsrf_cookie`, `connection`, and `table` are optional. A configuration file created before these options existed keeps working with the defaults shown above. Browsers reject `SameSite=None` cookies that are not `Secure`. When `same_site` is `none`, Doppar always marks the session cookie as secure. @@ -63,6 +65,7 @@ Browsers reject `SameSite=None` cookies that are not `Secure`. When `same_site` |--------|-----------|----------|--------| | `file` | Files on the server | Most applications | One server, or a shared filesystem | | `cookie` | The browser, in an encrypted cookie | Small payloads and servers with no writable storage | About 4 KB in total. Cannot be revoked from the server | +| `database` | A database table | Several servers behind a load balancer, and listing or ending a user's sessions | One small query per request | | `array` | Process memory | Automated tests | Lost when the process ends | ### File Driver @@ -83,7 +86,64 @@ Keep these constraints in mind before choosing it: - **Serialization.** Values must come back from JSON unchanged. Store strings (valid UTF-8), numbers, booleans, `null` and arrays. Objects would silently turn into arrays and binary data cannot be encoded, so both throw a `SessionException` that names the key. - **Revocation.** There is no server-side record, so `destroy()` and `session:clear` cannot invalidate a cookie that has already been issued. A copied cookie stays valid until it expires or you rotate `APP_KEY`. -> **Note:** Prefer the `file` driver for anything that holds authentication state or large values, such as validation errors and old input. +> **Note:** Prefer the `file` or `database` driver for anything that holds authentication state or large values, such as validation errors and old input. + +### Database Driver + +The `database` driver stores each session as a row in a database table. Every server that shares the database shares the sessions, so it suits applications that run on several servers. It also records which user each session belongs to, which lets a user see where they are signed in and sign out of other devices. See [Logging Out Other Devices](/versions/4.x/authentication#logging-out-other-devices). + +It works with MySQL and MariaDB, PostgreSQL, and SQLite. + +Create the table, then switch the driver: + +```bash +php pool session:table +php pool migrate +``` + +```toml +SESSION_DRIVER = "database" +``` + +`session:table` writes a migration for the table named by the `table` option: + +```php +Schema::create('sessions', function (Blueprint $table) { + $table->string('id', 128)->primary(); + $table->unsignedBigInteger('user_id')->nullable()->index(); + $table->string('ip_address', 45)->nullable(); + $table->text('user_agent')->nullable(); + $table->longText('payload'); + $table->unsignedInteger('last_activity')->index(); +}); +``` + +| Column | Holds | +|--------|-------| +| `id` | The session ID | +| `user_id` | The user signed in through the default auth actor, or `null` for a guest | +| `ip_address` | The client's IP address, as seen through your [trusted proxies](/versions/4.x/trust-proxies) | +| `user_agent` | The client's user agent, limited to 1024 printable characters | +| `payload` | The session data, serialized and base64 encoded | +| `last_activity` | When the session was last used, as a Unix timestamp | + +> **Note:** If your users have UUID or ULID keys, change the `user_id` column to `$table->string('user_id', 36)->nullable()->index();` before you migrate. + +The table and connection default to `sessions` and your default database connection. To change them, add these options to `runtime/config/session.php`; `connection` must name one of the connections in `runtime/config/database.php`: + +```php +'connection' => env('SESSION_CONNECTION', 'sessions'), +'table' => env('SESSION_TABLE', 'sessions'), +``` + +The driver is built to stay cheap on every request: + +- Loading a session is one lookup by primary key, and saving it is a single upsert. Statements are prepared once per connection and reused. +- A request that does not change the session does not write it. Its expiry is refreshed at most about once a minute. +- The database is only contacted once the session is used, so requests that never touch it never connect. +- Expired sessions are removed in bounded batches using the `last_activity` index. + +If the table is missing, the first request that uses the session throws a `SessionException` that says to run `php pool session:table`. ### Array Driver @@ -183,7 +243,9 @@ $request->session()->flush(); $request->session()->invalidate(); ``` -A session that is left with no data is removed when the request finishes, and its cookie is expired, so clearing a session never leaves an empty one behind. +A session that is left with no data is removed when the request finishes, and its cookie is expired, so clearing a session never leaves an empty one behind. If another request saved something into the session in the meantime, that is kept: the session is only removed when nothing is left once both requests' changes are taken together. + +`destroy()`, `flush()`, and `invalidate()` end the session, and they are applied even if the request then fails with an exception, so a logout is never lost. ## Regenerating the Session ID @@ -239,7 +301,7 @@ Endpoints that authenticate with tokens, such as a JSON API, do not need a sessi - Nothing is stored and no session cookie is sent - A session cookie sent by the client is ignored -To make a group of paths stateless, list them in `runtime/config/session.php`. Patterns are matched against the request path and may use `*` as a wildcard. +To make a group of paths stateless, list them in `runtime/config/session.php`. Patterns are matched against the request path and may use `*` as a wildcard. Matching ignores case, and the leading slash is optional, so `api/*` and `/api/*` are the same pattern. Paths that skip CSRF verification and `Request::is()` use the same rules. ```php 'stateless' => ['api/*', 'webhooks/*'], @@ -302,11 +364,17 @@ php pool session:clear Removes every stored session, which signs out all users. It has no effect on the `cookie` driver, because those sessions live in the browser. +```bash +php pool session:table +``` + +Creates the migration for the `database` driver's table. See [Database Driver](#database-driver). + ## Concurrent Requests A session is not locked while a request runs, so several requests from the same browser run in parallel, which is what makes pages that issue many AJAX calls fast. -When a request saves the session, the file driver briefly locks it, reads the session as other requests have left it, and applies only what this request changed: keys it added, changed or removed, including inside arrays such as flash data. The lock covers a small group of sessions, so visitors who are unrelated to each other never wait for one another. Two requests that touch different keys, such as one adding to the cart while another flashes a message, both keep their changes. +When a request saves the session, the `file` and `database` drivers briefly lock it, reads the session as other requests have left it, and applies only what this request changed: keys it added, changed or removed, including inside arrays such as flash data. The `file` driver's lock covers a small group of sessions. The `database` driver locks the one session only, with `GET_LOCK()` on MySQL and MariaDB and an advisory lock on PostgreSQL; on SQLite, which allows one writer at a time, it uses a short write transaction. Either way, visitors who are unrelated to each other never wait for one another. Two requests that touch different keys, such as one adding to the cart while another flashes a message, both keep their changes. Lists are merged by what each request added and removed, not by position. Two requests that each append to the same list both keep their item, and the same is true when both create the list for the first time. @@ -346,6 +414,8 @@ You may add a driver by implementing `Phaseolies\Session\Contracts\SessionStoreI A driver that can lock a session may also implement `Phaseolies\Session\Contracts\LockableSessionStore` and its `synchronized(string $id, callable $callback): mixed` method. Doppar then merges concurrent changes inside it, as the file driver does. +A driver that knows which user each session belongs to may also implement `Phaseolies\Session\Contracts\UserSessionStore`. Its `userSessions(int|string $userId): array` and `destroyUserSessions(int|string $userId, ?string $exceptId = null): int` methods let `auth()->sessions()` and `auth()->logoutOtherDevices()` list and end a user's sessions, as the database driver does. + ```php session()`, the `session()` helper, or the `Session` facade, which work with every driver. - **`csrf_token()` creates the token on demand.** Code should call it rather than reading `_token` from the session directly. -- **Session changes are not saved when a request ends with an uncaught exception.** The session is saved just before the response is sent. A request that ends in an unhandled error, a `500`, never gets that far, so flash data and other changes made before the failure are dropped. With PHP's native sessions they were saved when the script ended, so code that flashes a message and then throws must catch the exception or save the data another way. Requests that end with an HTTP exception, such as `abort(404)`, and validation failures that redirect back with their errors, are still saved. +- **Session changes are not saved when a request ends with an uncaught exception.** The session is saved just before the response is sent. A request that ends in an unhandled error, a `500`, never gets that far, so flash data and other changes made before the failure are dropped. With PHP's native sessions they were saved when the script ended, so code that flashes a message and then throws must catch the exception or save the data another way. A logout is the exception: `destroy()`, `flush()`, and `invalidate()` are still applied, so a user is never left signed in by a request that failed after logging them out. Requests that end with an HTTP exception, such as `abort(404)`, and validation failures that redirect back with their errors, are saved as usual. +- **Changes made while a streamed response is sent are not saved.** The session is saved before the response is sent, because that is when its cookie is added. A `StreamedResponse` callback runs after that, so a value it writes to the session is lost. Write to the session before returning the response. +- **Error pages can use the session.** The page of an HTTP exception, such as a custom `403` view that renders a form with `csrf_token()`, is built before the session is saved, so what it writes is kept and its form can be submitted. - **The `XSRF-TOKEN` cookie is sent lazily.** It is only sent once a token exists, for example after a page renders a form or calls `csrf_token()`. A front end that reads the cookie instead of a page, such as an axios or fetch based client that never loads a form, will get a `419` on its first `POST`. Expose the token with `csrf_token()` (a meta tag or a route that returns it), or set `'always_send_xsrf_cookie' => true` in `runtime/config/session.php`. See [The XSRF-TOKEN Cookie](/versions/4.x/csrf-protection#the-xsrf-token-cookie). diff --git a/versions/4.x/upgrade.md b/versions/4.x/upgrade.md index bee0c64..d404163 100644 --- a/versions/4.x/upgrade.md +++ b/versions/4.x/upgrade.md @@ -19,6 +19,7 @@ meta: - [Environment File Converted to TOML](#environment-file-converted-to-toml) - [Mail Transport Switched to Symfony Mailer](#mail-transport-switched-to-symfony-mailer) - [Checklist](#checklist) + - [Upgrading Within 4.9: Request and Response Changes](#upgrading-within-4x-request-and-response-changes) ## Upgrade Guide @@ -262,3 +263,93 @@ Work through these in order — each step assumes the previous one is already do > Do this on a dedicated branch. The steps are mechanical but touch nearly every file in the project — keeping it separate from feature work makes the diff reviewable. For the full list of what's new in 4.x beyond these breaking changes, see the [Release Notes](/versions/4.x/releases). + +## Upgrading Within 4.9: Request and Response Changes + +Doppar 4.9 fixes a number of inconsistencies in the HTTP request and response layer. Most applications need no changes. Review the items below if your code relies on any of them. + +### HEAD requests are routed to GET routes + +A `HEAD` request to a path that only has a `GET` route used to return `404`. It is now handled by the `GET` route, with that route's middleware, and returns headers only. Uptime monitors, link checkers and proxies that probe pages with `HEAD` will now get the real status (for example `200`, or a `302` to the login page for protected routes). An explicit `Route::head()` route still takes precedence. See [HEAD Requests](/versions/4.x/routing#head-requests). + +### Method overrides only apply to POST requests + +The `_method` field and the `X-HTTP-Method-Override` header are now only honoured on real `POST` requests. Before, they applied to any method, so a plain link such as `GET /posts/1?_method=DELETE` reached the `DELETE` route. On API routes, which have no CSRF check, a link or `` tag could trigger it. HTML forms already send these overrides with `method="POST"`, so they keep working. If a client sends an override on a non-`POST` request, switch it to `POST` or send the real method directly. + +A `_method` override is also only read from the request body, not the query string. `POST /posts/1?_method=DELETE` is now handled as `POST`, and a bad value there such as `?_method=garbage` no longer causes a `400`. Send `_method` as a form field, or use the `X-HTTP-Method-Override` header. A query-string `_method` is no longer part of `all()`/`input()`, but `$request->query->get('_method')` still returns it. + +### API errors are always JSON + +An HTTP exception on an API route (`/api/*`) or an AJAX request now always gets a JSON error body, matching `abort()`. Before, the bundled HTML error page was sent instead whenever a view existed for that status. + +### Client IP and scheme now honor trusted proxies + +`ip()`, `ips()` and `scheme()` now use the same proxy-aware logic as `getClientIp()`, `getClientIps()` and `getScheme()`. Previously they always returned `REMOTE_ADDR` and the raw `HTTPS` server value, even when [trusted proxies](/versions/4.x/trust-proxies) were configured. + +If your application runs behind a trusted proxy, `ip()` now returns the real client address instead of the proxy's. Check any code that stores, rate-limits on, or allow-lists IPs. + +`ips()` now returns the same list as `getClientIps()`, with trusted proxies removed. It is ordered from most to least trusted: the first element is the closest address that isn't a trusted proxy (what `ip()` returns), and the last is the originating client as reported by the forwarding headers. + +```php +// Behind a trusted proxy, with X-Forwarded-For: 203.0.113.1 +$request->ip(); // before: "10.0.0.5" (proxy) now: "203.0.113.1" +$request->ips(); // now: ["203.0.113.1"] + +// With X-Forwarded-For: 203.0.113.1, 198.51.100.2 (198.51.100.2 is not trusted) +$request->ip(); // "198.51.100.2" — the closest untrusted hop +$request->ips(); // ["198.51.100.2", "203.0.113.1"] +``` + +If a trusted proxy sends a `Forwarded` header and an `X-Forwarded-*` header that disagree, the first IP lookup throws `Phaseolies\Http\Exceptions\ConflictingHeadersException`, a `400 Bad Request`. Later lookups on the same request return the connecting address, so error pages and logs still work. Before, this case threw a generic `\Exception`, and later lookups returned `0.0.0.0`. + +### `response()` serializes models and `JsonSerializable` objects as JSON + +`response($value)` now applies the same rules as returning `$value` from a route. Entity models and other `JsonSerializable` objects produce a JSON response with `Content-Type: application/json`. Before, they were cast to a string and sent as `text/html`. + +Returning an object that implements only `Stringable` from a route now sends its string value. Before, it was JSON-encoded. See [Returning Arrays and Objects](/versions/4.x/responses#returning-arrays-and-objects) for the full rules. + +### `response()->redirect()` returns a `RedirectResponse` + +It now returns `Phaseolies\Http\Response\RedirectResponse` with a standard HTML redirect body, the same as `redirect()->to()`. Before, it returned a plain `Response` with an empty body. Code that asserted on an empty body needs updating. + +### Request input + +- `input()` called without a key now returns all input. Before, it returned `null`. +- The public `$request->input` property, an internal cache, was removed. Use `input()` or `all()` instead. +- `all()` is now typed `array` (it was `?array`). It never returned `null` in practice. +- `failed()` no longer accepts an `$excludeKeys` argument. It always returned every validation error, and still does. + +### Request body parsing + +- A JSON request with an empty body is now treated as having no body. Before, it failed with a `500` error. +- A JSON body that decodes to a scalar (for example `"text"` or `42`) is ignored. Before, it failed with a `500` error. +- A request with malformed JSON now gets a `400 Bad Request` response (JSON for API routes, the bundled `400` error page for web routes). Before, it crashed with an empty `500` response, because the error handler failed while re-reading the body. +- An unsupported method or method override (an `X-HTTP-Method-Override` header or `_method` field such as `BOGUS`, or a non-string such as `_method[]=PUT`) now gets a `400 Bad Request` too. Before, it crashed with an empty `500`. `getMethod()` now returns the real request method for such requests, and throws `BadRequestHttpException` instead of `InvalidArgumentException` when it meets an invalid value. +- If you need the underlying error for either case, call `$request->getMalformedRequestError()`. +- `application/x-www-form-urlencoded` bodies now keep query-string parameters on every method, including regular form `POST`s, matching how JSON bodies were already handled. For example, posting `name=d` to `/save?page=2` now gives `all() === ['name' => 'd', 'page' => '2']`. Before, `page` was dropped. If a body field and a query parameter share a name, the body value wins. +- Numeric field names keep their keys. Posting `10=a&20=b` now gives `[10 => 'a', 20 => 'b']`. Before, the form-body fix above re-indexed and duplicated them as `['a', 'b', 'a', 'b']`. `merge()` keeps numeric keys too. + +### Response API + +- `setException()` now requires a `Throwable` or `null`. +- `withHeaders()` now accepts a `HeaderBag` as well as an array, and returns `static`. +- `text()` now updates the status text along with the status code, so `text('...', 404)` sends `404 Not Found` instead of `404 OK`. +- `StreamedResponse` and `BinaryFileResponse` now guard `setBody()` instead of the unused `setContent()`/`getContent()` methods, which were removed. Passing a string to `setBody()` on either throws a `LogicException`. + +### Removed classes, methods and constants + +| Removed | Use instead | +| --- | --- | +| `Phaseolies\Http\Response\BaseJsonResponse` | `Phaseolies\Http\Response\JsonResponse` | +| `StreamedResponse::DEFAULT_ENCODING_OPTIONS` | `JsonResponse::DEFAULT_ENCODING_OPTIONS` or `StreamedJsonResponse::DEFAULT_ENCODING_OPTIONS` | +| `Response::headers_send()` | Nothing. It was an internal helper | +| `Request::initializeHeaders()`, `prepareRequestUri()`, `prepareBaseUrl()`, `preparePathInfo()` | Nothing. They were protected and never called | +| `Request::isValidRequest()` | Nothing. It was no longer called, and its rule that a proxied request must carry *every* trusted header would reject legitimate traffic. Use `isFromTrustedProxy()` to check whether a request came through a trusted proxy | + +### JSON output respects `jsonSerialize()` + +JSON responses now call an object's own `jsonSerialize()`, including for models inside a collection (`return User::all()`) and for loaded relations (`$user->posts`, `$post->author`). Before, models were converted with `toArray()`, which skipped a custom `jsonSerialize()` and could expose fields it was meant to hide. A model's own `toArray()` still shapes its JSON. Calling `toArray()` directly still serializes relations with their `toArray()`. Models that don't override `jsonSerialize()` produce exactly the same JSON as before. + +### Error views + +Bundled error pages (`404`, `500`, and so on) are now resolved relative to the framework package rather than a hard-coded `vendor/doppar/framework` path. Your own views in `templates/views/errors/{status}.odo.php` still take precedence. In setups where the framework isn't installed under `vendor/doppar/framework` (for example a path repository), `abort()` now renders the bundled pages instead of a bare exception. To find the view used for a status, call `Response::resolveErrorView($status)`.