This chapter moves an application from Suprnova 3.2.1 to 4.0.0. Each section matches one entry of the 4.0.0 section of the changelog, which carries the full detail of every change.
Who this chapter is for
- A person upgrading an application from 3.2.1 to 4.0.0.
- An agent doing the same upgrade. The headings are exact strings, so a search for a changelog entry's bold lead sentence lands on its section.
How to use this chapter
- Find the bold lead sentence of a changelog entry, such as Typed factory counts.
- Search this chapter for it. It heads a
###section, word for word. - Read the three parts of the section and make the edit:
- Before: what your application saw on 3.2.1.
- Now: what it sees on 4.0.0.
- Do: the edit that keeps it working, or "Nothing to do." when none applies.
Sections are grouped by area, in the order of the
documentation index. Every ### Changed entry has a
section; the 4.0.0 changelog has no ### Removed heading, and its removals
are ### Changed entries. After the areas,
Fixed entries that change behaviour
covers the fixes that change what an application observes,
Added entries that change behaviour
covers additions that change an existing path, and
Public types with new fields or variants
covers types that gained fields without an entry of their own. Every ### Fixed
entry of the release is either a section here or listed under
Fixes with no upgrade step.
Upgrade in this order
-
Bump the pin. Point every
suprnovadependency inCargo.tomlat the release, in[dependencies]and[dev-dependencies]alike, then update the lock file and the CLI:suprnova = { git = "https://github.com/eas4ai/suprnova.git", tag = "v4.5.1" } -
Build. Run
cargo check --all-targets. The compiler names each type or method whose shape changed; search this chapter for that name. -
Follow the compile errors until the build is clean. Each one has a Do: line with the edit.
-
Then work through the behaviour changes for the areas your application uses. Start with Configuration and environment, Cache and Migrations and schema: an unset
APP_ENV, the Redis cache prefix, unsigned keys and the MySQL collation change how a deployment boots and what it finds in its stores. -
Run
suprnova migrate, then your test suite.
Configuration and environment
Runtime threads have an 8 MiB stack.
Before: 3.2.1's #[suprnova::main] built the Tokio runtime with Tokio's
default stack, 2 MiB for each worker and blocking thread.
Now: Each runtime thread has an 8 MiB stack, the size Linux gives the main thread. The size is reserved address space; a thread uses memory only as its stack is touched.
Do: Nothing to do. An application that set RUST_MIN_STACK to keep a
debug build from overflowing under a Live action can drop it.
An unset APP_ENV is production.
Before: An unset APP_ENV was local: .env.local loaded and none of
the production checks ran.
Now: An unset APP_ENV is production. Debug is off, APP_KEY is
required, the production refusals apply, and db:seed, migrate:fresh and
suprnova migrate:fresh ask for --force. .env.local still loads; no
other .env.<environment> file does.
Do: Set APP_ENV in every environment, test runs and CI included. For
development, use local, as the scaffold's .env does:
APP_ENV=local
The server caps concurrent connections at 10000 by default.
Before: An unset or blank SERVER_MAX_CONNECTIONS meant no cap: the
server accepted every concurrent connection the operating system handed
it.
Now: An unset or blank SERVER_MAX_CONNECTIONS caps concurrent
connections at 10000 (DEFAULT_MAX_CONNECTIONS), and
ServerConfig::builder() and Server::new start from the same cap. Once
10000 connections
are open, the server accepts the next one only when another closes; until
then it waits in the kernel's accept backlog. Zero or a value that does not
parse still falls back to 10000 with a warning.
Do: Set SERVER_MAX_CONNECTIONS explicitly where a deployment holds
more than 10000 connections at once, such as many WebSocket or SSE
clients, and raise the file-descriptor limit to match. For no cap, register
the server configuration with the builder's opt-out:
use ;
register;
The service container
#[service(impl = ...)] parses.
Before: #[service(impl = ...)] failed to parse: impl was refused as
a keyword.
Now: The named form registers the implementation.
Do: Nothing to do.
Routing and controllers
A wrong method answers 405.
Before: A path that only a route of another method matched answered
404, an OPTIONS request included, or ran the fallback route when one
was registered.
Now: It answers 405 with Allow and Laravel's message, and an
OPTIONS request answers 200 with Allow. Both pass through the global
middleware. A path no route matches keeps the 404 or the fallback.
Do: Nothing to do. A client or test that expects 404 for a wrong
method expects 405.
RouteBinding is the one binding trait
Before: A type implemented AutoRouteBinding by hand, and an older
RouteBinding trait with param_name existed beside it.
Now: RouteBinding is the one trait, shaped like Laravel's
UrlRoutable. AutoRouteBinding is a second name for it, and
<T as AutoRouteBinding>::from_route_param(value) keeps working. The old
trait and the unused route_binding! macro are gone.
Do: Rewrite a hand-written impl AutoRouteBinding as
impl RouteBinding with its methods, as Routing shows, and
remove any call of the removed macro. Every #[suprnova::model] implements
it already.
A #[handler] inside an impl block names its type
Before: A #[handler] method inside an impl block compiled without
naming its type.
Now: It must name the type. Without it, the build fails with an error that names the fix.
Do: Add the type to each such attribute, as Controllers shows:
A dotted resource name nests.
Before: Router::resource("users.posts", ...) registered
/users.posts/{users.post}.
Now: It registers /users/{user}/posts/{post}, as Laravel's
Route::resource does.
Do: Move clients to the nested paths, and read the post parameter
where handlers read users.post.
A route key that does not parse answers 404.
Before: A route key that did not parse as the key's type answered 400
and repeated the value. A key that broke a unique_id key's format was
looked up as given and answered the 404 a missing row gets.
Now: It gets the 404 body a missing row gets, naming the model and
never the value.
Do: Nothing to do. A client or test that expects the 400 expects
404.
The bare x::Model form no longer binds a soft-deleted row
Before: The bare x::Model form bound a soft-deleted row of a
soft-deleting #[model]'s table.
Now: It no longer binds such a row unless the route calls
with_trashed().
Do: Call with_trashed() on each route that must bind trashed rows.
Bound arguments are bound before every other argument
Before: Arguments resolved in declaration order, so a form request declared before a bound model validated first.
Now: Bound arguments resolve first, after the route's middleware, so a
missing row answers 404 before a form request is validated.
Do: Nothing to do. A test that expects a 422 for a request whose
bound row is missing expects 404.
The router refuses a route it cannot run, at startup.
Before: A handler that read a parameter its path did not declare failed at request time, and two body readers were a compile error. 3.2.1 had no binding fields, scoped child bindings or route binders.
Now: A handler that reads an undeclared parameter, reads the body twice,
names a binding field that is not a parseable column, scopes a child through
an undeclared relation, or uses a binder that cannot run is an error from
the boot path. Two body readers are a startup error, except two Request
arguments, still a compile error.
Do: Boot the application once after the upgrade and fix each route the error names.
Middleware
A default middleware priority.
Before: Middleware ran in the order a route or the global list gave it.
Now: The priority list starts as Precognitive, SessionMiddleware,
AuthMiddleware and ThrottleRequestsMiddleware, so a route listing
AuthMiddleware before SessionMiddleware loads the session first, and a
route-listed Precognitive runs before a global SessionMiddleware.
Do: Nothing to do. To change the order, edit the priority list, as Middleware describes.
CSRF protection
CSRF checks every method but GET, HEAD and OPTIONS.
Before: CsrfMiddleware let QUERY and extension methods such as
PROPFIND through without a token.
Now: It checks them as it checks POST; a QUERY request without a
token gets 419.
Do: Send the CSRF token with QUERY and extension-method requests.
Regenerating the session issues a new CSRF token.
Before: regenerate_session_id() kept the CSRF token.
Now: It sets a new CSRF token beside the new id, so a token read before
the regeneration gets 419 after it.
Do: Read the token again after a sign-in or any other regeneration, as a page rendered after it does.
The CSRF field turns autocomplete off.
Before: csrf_field() rendered no autocomplete attribute.
Now: It renders autocomplete="off", so a browser does not restore a
stale token into it.
Do: Nothing to do.
Requests
Request::ajax() compares exactly.
Before: Request::ajax() was true for other spellings of
XMLHttpRequest too.
Now: It is true only for X-Requested-With: XMLHttpRequest.
Do: Nothing to do. A client that sends another spelling sends
XMLHttpRequest.
Request::host() reads the host as Symfony does.
Before: Request::host() kept the letter case the host was sent in, cut
everything after its last :, port or not, and refused no character.
Now: It lowercases the host, strips only a numeric port, and returns
None for a host with a character no host has, so http_host(),
scheme_and_http_host() and url() never carry such a host.
Do: Nothing to do. Code that compares hosts compares them in lower case.
Responses and redirects
File and disk responses answer byte ranges.
Before: File and disk responses ignored Range and sent the whole file.
Now: A GET range gets 206 with the selected bytes and
Content-Range, and an unsatisfiable range gets 416. Responses carry
Accept-Ranges: bytes and Last-Modified.
Do: Nothing to do.
A streamed file is one blocking task, not one per chunk.
Before: A file above 1 MiB took one blocking-pool task per 64 KiB chunk.
Now: One blocking task reads the whole file in 256 KiB chunks. The bytes
and Content-Length are unchanged.
Do: Nothing to do.
Redirect::intended ignores a stored network-path reference.
Before: A stored intended URL that starts with //, /\ or \, or
holds a control character, was followed.
Now: It is ignored in favour of the fallback.
Do: Store an external destination as an absolute URL.
Redirect::guest stores a page to come back to.
Before: Redirect::guest stored the request's own URL for every
request, so a sign-in after a POST or a JSON call went back to it.
Now: It stores the request's URL only for a GET that does not expect
JSON, and the session's previous URL for any other request. It never reads
the Referer.
Do: Nothing to do.
Deletion cookies follow the session configuration.
Before: The deletion cookie did not take Path, Domain and SameSite
from the session configuration, so it could miss the cookie it deleted.
Now: Cookie::forget, without_cookie and without_cookies take them
from SESSION_PATH, SESSION_DOMAIN and SESSION_SAME_SITE. An explicit
path or domain given to Cookie::forget_with still wins.
Do: Nothing to do. For a cookie set with another path or domain, use
Cookie::forget_with.
URL generation
Some string-pair route() calls need .as_str().
Before: route() and try_route() took a slice of string pairs, so the
compiler coerced many pair forms.
Now: They take bound values as well as strings, and a few forms no
longer compile: inferred names or values (s.as_ref(),
Default::default()), more than one dereference (&&&str,
&Box<String>), arrays mixing &String and &str, arrays of more than 32
&String pairs, and route used as a function pointer.
Do: Write .as_str(), &* or .as_slice(), or a closure in place of
the pointer:
route
URLs carry the public root.
Before: route(), try_route, route_with_params and
try_route_with_params returned the path without the public root.
Now: They put the public root (the path in APP_URL, or a trusted
prefix) in front. Signed URLs cover the root, LiveAssetCatalog::url
includes it, and RenderKeyInput has a public root field.
Do: Register routes without the APP_URL path, so the root appears once,
as URL Generation describes. Sign again any link signed before the
application gained a root. A RenderKeyInput struct literal names root.
url::current is the absolute URL without the query.
Before: url::current returned the path and the query, such as
/posts?page=2.
Now: It returns the absolute URL without the query: the APP_URL
origin, the public root and the path.
Do: Use url::full where you need the query, and compare
url::current with an absolute URL.
Session and cookies
Cookies follow the root.
Before: SessionConfig.cookie_path and the XSRF cookie path defaulted
to /.
Now: Both default to unset, which means the public root of each request. When a request carries two cookies of one name, the framework reads the first.
Do: Nothing to do. To keep / under a path prefix, set it:
SESSION_PATH=/
Validation
Merged validation hook errors.
Before: Form and multipart requests did not return the errors of both after-validation hooks together with the field rules' errors.
Now: Both hooks run after the field rules, and the response carries their combined errors.
Do: Nothing to do.
Classic form validation redirects.
Before: A failed form that prefers HTML got the validation error response.
Now: It redirects back, with the errors and the old text input flashed to the session.
Do: Render the session's errors and old input in your HTML forms.
An image wildcard leaves SVG out.
Before: 3.2.1 had no type/* wildcards: MimeType compared each
allowlist entry with the file's type exactly, and an SVG passed only when
the allowlist named image/svg+xml.
Now: A type/* wildcard admits every subtype except image/svg+xml.
Do: Nothing to do. To accept every image type but SVG, name image/*;
to accept SVG too, name image/svg+xml as well.
The numeric max message.
Before: #[validate(range(max = 10))] failed with another message.
Now: It reads must not be greater than 10, Laravel's wording.
Do: Update tests that assert the old message.
ImageFile accepts Laravel's image types.
Before: ImageFile also accepted other image types, such as TIFF, PSD,
ICO and JPEG XL.
Now: It accepts JPEG, PNG, GIF, BMP, WebP, AVIF, HEIC and HEIF, and refuses every other type.
Do: Accept any other type through a MimeType allowlist that names it.
Precognition
Precognition requires the route middleware.
Before: Every form request answered the Precognition header.
Now: Only a route that carries Precognitive does. Elsewhere the header
is ignored and the request runs for real.
Do: Add Precognitive to each route or group that serves live
validation, as Precognition shows:
post!.middleware,
Database rules in hooks follow the Precognition selection.
Before: AsyncRule::check_async, Exists::check_value and
Exists::check_each ran for every field of a precognitive request.
Now: They skip a field the request did not list, so they neither query nor fail. Errors a hook adds stay unfiltered.
Do: Nothing to do.
Error handling
Production browser failures show a minimal HTML view.
Before: With debug off, a browser got the same 5xx body as any other client.
Now: A browser's 5xx page shows its status and no error details. Inertia visits keep the application error page, and JSON clients keep their JSON response.
Do: Nothing to do.
Debug frames show source and separate error traces.
Before: Frames showed no source lines, and recorded source errors shared one trace.
Now: Application frames include nearby source lines when the file is readable, and each recorded source error has its own trace.
Do: Nothing to do.
Errors are reported through Exceptions.
Before: 3.2.1 had no Exceptions and no reportable callbacks. A 5xx
response wrote the framework error log line, and a console command's error
was printed but not logged.
Now: A 5xx response, each failed attempt of a queued job and a console
command's error run your Exceptions::reportable callbacks before the
framework error log line, and a console command's error is logged as well
as printed.
Do: Nothing to do. To act on these errors, register a callback with
Exceptions::reportable, as Errors describes.
The frame that created an error shows where.
Before: In a build without debug info, the innermost application frame had no location.
Now: It gets the error's creation site as its location.
Do: Nothing to do.
Logging
Daily logs keep seven files by default.
Before: The daily channel kept 14 files by default.
Now: The daily channel and LogChannel::daily keep 7 files unless
LOG_DAILY_DAYS or .days(...) sets another count; 0 keeps every file.
Do: To keep 14 files:
LOG_DAILY_DAYS=14
Syslog names the application.
Before: Syslog datagrams did not use APP_NAME as their ident.
Now: They use APP_NAME, with suprnova when it is unset, and keep each
record's facility and severity.
Do: Update syslog filters that match the old ident.
File and stream log channels keep LOG_LEVEL's bare level.
Before: The file and stream channels did not keep to LOG_LEVEL's bare
level.
Now: single, daily, monthly, stderr, errorlog and syslog keep
only records at or above it, debug when it names none, and a bare level
that is no level fails the boot.
Do: Make sure LOG_LEVEL names a real level, or leave it unset.
Console
The console binary ends with the command's exit code.
Before: A new project's src/bin/console.rs ended with
Err(_) => ExitCode::FAILURE, so every failed command exited with 1.
Now: The scaffold ends with the code of FrameworkError::exit.
Do: In src/bin/console.rs, replace the error arm:
match result
down prints Laravel's lines.
Before: down printed its own wording.
Now: It prints Laravel's lines: Maintenance mode options updated. when
already down, You may bypass maintenance mode via [<APP_URL>/<secret>].,
and Failed to enter maintenance mode: <error>. on a failure.
Do: Update scripts that read the output.
Cache
CacheConfig gained connection and lock_connection.
Before: CacheConfig had no connection or lock_connection field.
Now: It has both.
Do: A struct literal names both fields, or spreads the default:
let config = CacheConfig ;
The Redis cache moves to the cache connection and CACHE_PREFIX.
Before: The Redis cache used the default connection, and REDIS_PREFIX
(suprnova_cache: by default) was its key prefix.
Now: It runs on the cache connection (REDIS_CACHE_DB, 1 by default)
under REDIS_PREFIX, then CACHE_PREFIX (the slug of APP_NAME and
-cache-), then the key. Cache::flush deletes only keys under both
prefixes. An upgraded deployment finds its Redis cache empty.
Do: Let the cache refill, or keep the old keys. If you had set
REDIS_PREFIX, put that value in CACHE_PREFIX instead:
REDIS_CACHE_CONNECTION=default
REDIS_PREFIX=
CACHE_PREFIX=suprnova_cache:
Events
Unique queued listeners take the lock.
Before: A QueuedListener whose job declares unique_id pushed a job
for every event, while the job's lock was held too.
Now: It pushes through Queue::push_unique, so a second event under the
lock pushes nothing and dispatches UniqueJobSkipped.
Do: Nothing to do.
File storage
Storage::purge takes the disk name.
Before: Storage::purge() dropped every disk.
Now: purge(name) drops one disk and reports whether it was registered;
purge_all() drops every disk.
Do:
purge_all; // was Storage::purge()
The implicit default disk is local.
Before: With no default disk named in code or FILESYSTEM_DISK,
Storage::default_disk() returned an error.
Now: It returns local, or an error naming local and
FILESYSTEM_DISK when no local disk is registered.
Do: Nothing to do.
S3 accepts Laravel's AWS variables.
Before: S3 disks read the S3_* names, with two exceptions: the region
fell back to AWS_REGION and AWS_DEFAULT_REGION, and a disk with no
S3_ACCESS_KEY took its credentials from the AWS provider chain, which
reads AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY. AWS_BUCKET,
AWS_URL, AWS_ENDPOINT and AWS_USE_PATH_STYLE_ENDPOINT were not read.
Now: When an S3 name is unset, the bucket, region, credentials, public URL, endpoint and path-style settings read Laravel's AWS names. Each explicit S3 value wins.
Do: Nothing to do. Check that a stray AWS_* variable does not now
configure a disk you left to its defaults.
make_directory takes a path without its trailing slash.
Before: make_directory("a/b") failed on every disk.
Now: It creates the directory as "a/b/" does. An empty path and a path
that ends in / reach the backend unchanged.
Do: Nothing to do.
HTTP client
The HTTP fake records a request as it is sent.
Before: The fake recorded the headers you added and the URL as you
passed it, without the Content-Type a JSON or form body set or the
client's user agent. 3.2.1 had no base URL, URL parameters, query builder,
multipart body or global middleware.
Now: It records the request as it is sent.
Do: Update assertions on recorded headers that did not expect the body's
Content-Type or the user agent.
Images
The built-in image driver's PNG, BMP and WebP codecs come from the eas4ai forks
Before: The built-in driver used the upstream oxideav-png,
oxideav-bmp and oxideav-webp crates.
Now: It uses the eas4ai forks. Output changes in three cases: pixels decoded from a lossy WebP (by up to 16 levels), a PNG with more than 1 MiB of filtered data (its bytes can differ), and lossless WebP with transparency (written in the simple lossless layout).
Do: Nothing to do. Refresh test fixtures that compare encoded bytes or decoded lossy WebP pixels exactly.
Images are upright and carry no metadata.
Before: Neither driver applied the EXIF orientation. The built-in driver
wrote output with no metadata, the orientation tag included, so a photo that
relied on the tag came out as stored; the magick driver kept the tag and
the metadata. Rotation corners and flattened transparency were black in JPEG
and GIF.
Now: Images are oriented by their EXIF tag on decode, and processed output keeps only the ICC profile. Those corners are white, and GIF output is converted to sRGB.
Do: To keep the pixels as the sensor wrote them:
IMAGE_AUTO_ORIENT=false
The image types changed shape.
Before: Transformation and ImageConfig were exhaustive,
Transformation::Rotate(f32) took a bare angle, and
Image::store(disk, path) stored at a path you gave.
Now: Both are #[non_exhaustive], Rotate is
Rotate { degrees, background }, store(directory, disk) stores under a
generated name and returns the path, and store_as(directory, name, disk)
takes a name.
Do: Build ImageConfig from default() or from_env(), give a match on
Transformation a wildcard arm, and store with a directory and a name:
// was: image.store("public", "avatars/1.jpg").await?
let path = image.store_as.await?;
No image driver receives a zero side.
Before: A side of 0 reached the driver, and the magick driver failed
on the 0x0 geometry.
Now: Image::resize, cover, resize_width and resize_height record
a side of 0 as 1.
Do: Nothing to do.
ImagePipeline carries the settings of its custom steps.
Before: ImagePipeline had no settings field.
Now: settings holds what Image::transform_with recorded, and a
custom driver runs a custom step with
CustomTransformation::apply_with(pixels, &pipeline.settings).
Do: A struct literal names settings or ends with the default:
let pipeline = ImagePipeline ;
Localization
lang/vendor is not a locale.
Before: The translator read lang/vendor as a locale directory.
Now: It skips it; lang/vendor holds overrides of namespaced catalogs.
Do: Nothing to do. Rename a locale you named vendor.
Starter kits ship no translations.
Before: suprnova new gave a project lang/en/app.ftl, registered
LocaleMiddleware and LocaleShare in its bootstrap, and in the React, Vue
and Svelte kits a src/lib/lang module whose t() the home and error pages
called.
Now: A new project has none of them, and its pages render English text.
The framework's localization is unchanged: catalogs you add under lang/,
Lang::get, LocaleMiddleware and LocaleShare work as before.
Do: Nothing to change. An existing project keeps its own files; the next section covers them under server-side rendering.
A fresh Inertia starter app hydrates its first page without a mismatch.
Before: The React, Vue and Svelte kits translated their pages in the
browser. With server-side rendering, on by default in 4.0, the server
rendered the raw message key (welcome) because the catalog loads only in
the browser, and the browser hydrated with the translated text, so React
reported a hydration error and Vue and Svelte warned.
Now: A new project's pages render the same text on the server and in the browser.
Do: In a project scaffolded from an earlier release that keeps server-side rendering on, choose one:
- Remove the kit's browser translations as a new project does: delete
frontend/src/lib/lang.ts(lang.svelte.tsin Svelte) andfrontend/src/types/lang-keys.ts, render the app withoutLangProviderand without awaitinginitLanginmainandssr, and replace eacht()call with its text. - Keep translations and move them to the server: call
Lang::get(or__!) in the handler and pass the text in the page's props, which the server and the browser render alike. A Fluent catalog loaded in the browser renders raw keys during server-side rendering, so a page rendered on the server should not depend on it. See Localization.
Mail and notifications
One attachment per name and bytes.
Before: A message sent every attachment, duplicates included.
Now: It drops an attachment whose name and bytes equal an earlier one's; two attachments with one name and different bytes are both sent.
Do: Nothing to do.
Notification mail without a subject.
Before: An empty MailRendering subject was sent empty.
Now: It is the notification's name in title case (InvoicePaid gives
Invoice Paid).
Do: Set a subject where you want another one.
Processes
Processes remove inherited environment variables.
Before: env could only set an inherited variable.
Now: Passing None to env removes the variable before the program
starts.
Do: Nothing to do.
TTY processes require terminal input and output.
Before: A TTY process started whatever its input and output were.
Now: It requires terminal input and output, fails with an error naming the redirected stream, and captures no output.
Do: Run TTY processes from a terminal only, and read output from a process run without TTY.
Process diagnostics quote command arguments.
Before: Results and errors showed the arguments joined by spaces.
Now: They use POSIX shell quoting. Fake patterns still match the arguments joined by spaces.
Do: Update assertions on the diagnostic text.
Stopping a process accepts a signal.
Before: stop(grace) took the grace period alone.
Now: stop(grace, signal) also takes a signal. None sends terminate
and waits 10 seconds before killing the process and its descendants.
Do:
let result = process.stop.await?; // was stop(Duration::from_secs(5))
Process fake handlers accept closures.
Before: A fake handler was a fixed result.
Now: It can be a closure that reads the pending command, path, environment and input before it returns a result.
Do: Nothing to do.
Described process fakes replay mixed streams in order.
Before: A described fake did not replay its output and error-output lines in the order described.
Now: It replays mixed streams in order, and each line ends with one newline.
Do: Update assertions on a described fake's exact output.
Process run-count assertions default to once.
Before: assert_ran_times(command, times) took a count.
Now: assert_ran_times(command) checks one run, and
assert_ran_count(command, times) takes a count.
Do:
fake.assert_ran_count; // was fake.assert_ran_times("git push", 3)
A started fake process reveals its output a line at a time.
Before: A started fake process answered its whole output on the first read.
Now: Each output() or latest_output() call reveals the next line:
output() answers every line so far and latest_output() the new one. The
error output works the same way, and wait() still returns the whole
output.
Do: Read a started fake's whole output from wait().
Strings and numbers
Excerpts trim invisible characters.
Before: Cut ends kept invisible characters such as zero-width spaces.
Now: They remove Laravel's invisible character set, and characters inside the excerpt stay.
Do: Nothing to do.
Percentages round decimal ties half up.
Before: 0.12345 at four fraction digits formatted as 0.1234%.
Now: Decimal ties round half up, so it formats as 0.1235%.
Do: Nothing to do.
Special number spellings.
Before: Number formats had no spelling for values without a finite
decimal representation: a try_* form returned an error, and the
infallible forms wrote Rust's inf or NaN.
Now: They use ∞ and NaN.
Do: Nothing to do. A test that asserted inf asserts ∞.
Queues
Queue counts and clearing accept a queue name.
Before: Queue::size(), pending_size(), delayed_size(),
reserved_size() and clear() took no argument.
Now: Each takes an Option<&str> naming a queue, on every driver. SQS
uses SQS_QUEUE when you pass None.
Do:
let waiting = size.await?; // was Queue::size()
clear.await?;
SQS accepts FIFO queues.
Before: SQS sends carried no message group or deduplication ID.
Now: Jobs carry a message group and a deduplication ID, default and a
payload digest by default, and sends omit delays, so FIFO queues work.
Do: Nothing to do.
SQS overflow supports named cache stores.
Before: SQS overflow payloads went to a disk.
Now: SQS_OVERFLOW_STORE names a cache store and takes precedence over
the disk, and clearing a queue keeps other queues' payloads.
Do: Nothing to do.
Workers check failed_jobs before they start.
Before: run_worker returned nothing, and a worker started on a
failed-jobs table it could not write.
Now: run_worker returns Result<(), FrameworkError>. A worker whose
failed-jobs store is the database refuses to start on a table it cannot
write, and queue:work exits non-zero. Each failed job gets a fresh uuid,
and its connection is suprnova:<connection>, so Laravel's queue:retry
refuses it.
Do: Run suprnova migrate first, and handle the result:
run_worker.await?;
The after-commit decision follows an order.
Before: A push waited for the commit when the job's after_commit or
QUEUE_AFTER_COMMIT said so.
Now: The first of the push's own override, the job's
after_commit_choice, the connection's setting and QUEUE_AFTER_COMMIT
decides, for push, push_unique and bulk alike.
Do: Nothing to do. To push a job before the commit while its connection
waits, return Some(false) from after_commit_choice.
Zero tries is unlimited.
Before: A job whose Job::max_tries() returned 0, or a push whose
EnvelopeOverrides::max_tries was Some(0), was dead-lettered before it
ran.
Now: Zero is unlimited, as Laravel's $tries = 0 and the worker's
--tries=0 are. The job is retried until it succeeds. An error named with
dont_retry or dont_retry_when, a middleware such as FailOnException, a
timeout under fail_on_timeout and a worker started with --tries still
end it.
Do: Nothing to do for a job that declares a bound. A job or a
notification that returns 0 from max_tries() now runs, and keeps running
while it fails: return 1 to run it once, or keep 0 and give it a backoff
with a cap. For details, see Unlimited tries.
Rate limiting
The in-memory rate limiter keeps answering while it sweeps.
Before: Each sweep of the in-memory limiter held one lock over every bucket and stalled every throttled request.
Now: The buckets sit in a sharded map, and a sweep locks one shard at a time. Answers are unchanged, and no request fails with "rate limiter poisoned".
Do: Nothing to do.
Rate-limiter keys are cleaned as Laravel cleans them.
Before: RateLimiter::clean_rate_limiter_key did not apply PHP's
htmlentities.
Now: It applies htmlentities before it strips entity markers, so
café and cafe count in one bucket and a&b is stored as aab.
Do: Nothing to do. Counts under such keys start again.
Named limiters rename shared keys.
Before: Limits that shared a key were counted under re-keyed
suprnova:throttle:<hex>:... keys.
Now: RateLimiter::limiter gives each limit that shares a key its
fallback key, and throttle:<name> counts each limit under <name>:<key>,
so the middleware and a direct caller read the same buckets.
Do: Nothing to do. The old keys are not read, so these buckets start at zero after the upgrade.
One rate-limit header pair.
Before: The throttle middleware wrote its headers without regard to the headers a handler had set.
Now: It writes one X-RateLimit-Limit and X-RateLimit-Remaining pair,
for the limit with the fewest attempts left, and none when the handler's
response carries an equal or lower X-RateLimit-Remaining.
Do: Nothing to do.
Refused requests are not counted.
Before: A refused request still counted in the buckets.
Now: A refused request leaves every bucket's count where it was.
Do: Nothing to do.
throttle:60,1 counts signed-in users by user.
Before: The inline throttle counted every request by address.
Now: It counts a signed-in user by the user's identifier and a guest by address, each per path.
Do: Nothing to do. Counts start again after the upgrade.
Redis
Redis prefixes typed keys.
Before: Typed methods, scripts, blocking commands and typed pipeline methods used keys as given.
Now: They apply REDIS_PREFIX, by default the slug of APP_NAME
followed by -database-. Raw commands keep their arguments.
Do: To read keys written by 3.2.1, keep them unprefixed:
REDIS_PREFIX=
Redis events reach application listeners.
Before: Enabled Redis command and failure events reached the Redis listeners only.
Now: They also dispatch through EventFacade. Pipeline and transaction
commands stay outside these events.
Do: Nothing to do.
Task scheduling
Single-server tasks need a cache store.
Before: With no shared cache store, an on_one_server() task ran on
every server outside production, with a warning. In production,
schedule:work and schedule:run refused to start unless
SCHEDULE_ALLOW_MEMORY_LOCK_IN_PRODUCTION=true.
Now: A schedule with such a task and no cache store refuses to start, in every environment, and a tick with no store skips the task with an error log.
Do: Bind a shared cache store in your bootstrap before the scheduler starts.
Day helpers keep the schedule.
Before: A day helper after a frequency, time or cron call reset the
schedule, so .hourly().mondays() ran at midnight on Mondays.
Now: days, weekdays, weekends and sundays to saturdays replace
only the day-of-week field, so .hourly().mondays() is 0 * * * 1. Alone,
they still run at midnight.
Do: Check chains that put a day helper after a frequency; they keep the frequency.
Hour-only and seconds times.
Before: at, try_at, daily_at and try_daily_at did not read 9
or 09:30:00.
Now: They read 9 as 09:00 and 09:30:00 as 09:30.
Do: Nothing to do.
WebSockets
A WebSocket upgrade no longer waits behind the shutdown drain.
Before: An upgrade in flight during shutdown waited out the drain, up to 5 s, before its handler was registered.
Now: It no longer waits. The drain still aborts what runs after 5 s.
Do: Nothing to do.
Live
live:add --manifest installs only from a signed library tree.
Before: live:add --manifest installed a manifest without a signature,
a manifest held a version, and .suprnova-installed.json keyed its
digests in an earlier form.
Now: A third-party manifest sits in a library tree beside the
library.json that names its namespace and key, and installs only with a
valid signature. A manifest holds no version. .suprnova-installed.json
keys digests by the path from the project root, and live:add rewrites an
earlier record.
Do: Install third-party components from a signed library tree, as
Live Component Libraries describes, and remove
version from your own manifests.
Scaffolds ignore live:add's lock and journal.
Before: 3.2.1's live:add had no lock or journal, and a new project's
.gitignore listed neither.
Now: It lists /.suprnova-live.lock and /.suprnova-live-journal.json.
Do: Add both lines to your .gitignore:
A component's script can no longer replace a built-in.
Before: 3.2.1 had no script scan.
Now: live:add scans a component's scripts, and a write to a member of
a built-in object or function, such as Object.keys = f, is refused
(script-builtin).
Do: Nothing to do for an application that installs no such component.
If live:add refuses one, its author changes it as the refusal says.
Authentication
Suprnova never writes users.remember_token.
Before: The scaffold's User carried update_remember_token (the
api scaffold's did not), read id as i64 and its timestamps as
DateTime<Utc>.
Now: Suprnova never writes users.remember_token. The scaffold's User
drops update_remember_token, reads id as u64 and its timestamps as
Option<DateTime<Utc>>.
Do: Nothing to do. Your User keeps compiling. Keep id as i64 while
your users.id column is signed.
The verified gate answers each request.
Before: EnsureEmailVerifiedMiddleware::redirect_to redirected every
unverified request, JSON clients included.
Now: A request that expects JSON gets 403
Your email address is not verified., and any other request goes through
Redirect::guest, so Redirect::intended returns the user after
verification. new() still answers 403 to every caller.
Do: Redirect with Redirect::intended after verification to return the
user.
Verification refusals tell the two cases apart.
Before: EmailVerification::verify answered 400 for every refused
link.
Now: It answers 403 This action is unauthorized. for a live link of
another account or one mailed to an address the account no longer has, and
keeps 400 for an unknown, expired or used token.
Do: Update clients and tests that expect 400 in those two cases.
Verifying a verified account keeps its time.
Before: Verifying a verified account stamped a new time and fired
EmailVerified.
Now: verify consumes the token and returns the user ID, with no new
stamp and no EmailVerified.
Do: Nothing to do.
Checking a password writes nothing.
Before: 3.2.1 rewrote no password hash: validate_credentials on
EloquentUserProvider and DatabaseUserProvider only verified the
password, and a sign-in left an outdated hash as it was.
Now: It writes nothing. The sign-in (attempt, once and
Auth::logout_other_devices) rewrites the hash through
UserProvider::rehash_password_if_required, and a rewrite that fails still
fails the sign-in.
Do: Nothing to do. A custom UserProvider that rehashed in
validate_credentials moves that work to rehash_password_if_required.
Authorization
Nullable-user policies authorize guests.
Before: 3.2.1 had no nullable-user policies: a policy took &User, and
handler authorization answered a guest 401 before any policy ran.
Now: A policy accepting Option<&User> receives None for a guest and
decides whether the handler runs. A policy that requires a user keeps the
401.
Do: Nothing to do. To let a policy decide for guests, take the user as
Option<&User>, as Authorization shows.
Handler authorization accepts enum abilities.
Before: Handler authorization took string abilities only.
Now: An enum variant that implements Into<String> reaches the same
async gate.
Do: Nothing to do.
Denial codes survive authorize.
Before: GateResponse::authorize turned a denial built with with_code
into a domain error and dropped the code.
Now: It returns FrameworkError::Denial, and FrameworkError::code()
reads the code after ?. A denial without a code maps as before.
Do: Where code matched the domain error for a coded denial, match
FrameworkError::Denial or read code().
Hashing
Laravel's $2y$ hashes verify
Before: Laravel's $2y$ hashes of passwords of 72 bytes and longer did
not verify.
Now: They verify through Auth::attempt and Magnetar, judged on their
first 72 bytes as PHP does.
Do: Nothing to do.
Password hashing runs under one process-wide limit.
Before: Each async hash and verify took a blocking-pool thread with no limit.
Now: They share one limit, HASH_MAX_CONCURRENCY, which defaults to the
host's available parallelism. 0 or a value that is not a whole number is
refused when the hashing configuration loads.
Do: Nothing to do. If HASH_MAX_CONCURRENCY is 0 or not a whole
number, set a whole number or remove it.
Query builder and pagination
Query conditions follow SQL precedence.
Before: An or_ helper folded into the condition before it, so
where(a).or_where(b).where(c) was (a OR b) AND c.
Now: Every or_ helper adds a flat OR, so the same chain is
a OR b AND c. The grouped helpers keep their parentheses, and model scopes
still constrain the whole condition list.
Do: Review every chain with an or_ helper after another condition.
Where you mean the grouped form, use a grouped helper such as where_any,
or where_raw with parentheses, as Query Builder shows.
Null equality values compile to IS NULL.
Before: A null where or or_where comparison was bound after =,
which matches no row.
Now: Both builders compile it to IS NULL.
Do: Nothing to do. Such a query returns the rows it names.
Joined builders update and delete matching rows.
Before: DB::table and the model builder refused an update or delete on
a joined builder with an error, since the statement would have ignored the
join.
Now: Both builders keep the join when they write the base table, and
model deletion still honours soft deletes. The model builder also has
update and delete.
Do: Nothing to do. A joined update or delete now runs and touches only the rows the join matches.
Empty results have page one.
Before: Length-aware pagination with no matching rows reported a last
page of 0 and left the page-one link out.
Now: It reports a last page of one and includes the page-one link.
Do: Read the total, not the last page, to tell that a result is empty.
Migrations and schema
Unsigned MySQL IDs by default.
Before: id() and foreign_id() created signed BIGINT columns on
MySQL and MariaDB.
Now: They create BIGINT UNSIGNED, as Laravel does; Postgres and SQLite
keep signed columns. On a database with signed tables, a new table whose
foreign_id references an old signed key fails at migrate (MySQL errno
150), and an i64 model field cannot read an unsigned column.
Do: Keep signed IDs in every migration your binary runs. Without
#[suprnova::main], call Schema::set_unsigned_ids(false) before the
first migration. Or move to unsigned keys: convert the old keys, declare
keys as u64 in models, and use foreign_id or unsigned_foreign_id for
the columns that reference them. Migrations has the detail.
[package.metadata.suprnova.schema]
unsigned_ids = false
Nullable default timestamps.
Before: timestamps() created NOT NULL created_at and updated_at.
Now: It creates them nullable on every database. The scaffold documents the schema defaults and carries the signed-ID opt-out and the model cast setting commented out.
Do: Nothing to do. Existing tables keep their columns.
Laravel boolean storage.
Before: Boolean columns took SeaQuery's type for each backend.
Now: MySQL and SQLite boolean columns are tinyint(1) and read back as
false and true.
Do: Nothing to do. Existing columns keep their type.
Default float precision.
Before: Float columns were single precision on MySQL and Postgres.
Now: They use 53 binary digits, and the column modifier sets another precision.
Do: Nothing to do. Existing columns keep their type.
Whole-second zoned timestamps.
Before: Zoned timestamps kept fractional seconds on Postgres, and SQLite declared another type for them.
Now: Postgres uses timestamp(0) with time zone by default, and SQLite
declares datetime.
Do: Nothing to do. Existing columns keep their type.
Schema-qualified table names.
Before: A table name that holds a . was not read as a schema and a
table.
Now: On Postgres, Schema::create, table, drop, drop_if_exists
and rename read schema.table as that table in that schema, and a .
becomes _ in index and foreign key names. On MySQL and SQLite, a
qualified name is refused with an error naming it.
Do: On MySQL and SQLite, give tables names without a ..
MySQL tables default to utf8mb4_unicode_ci.
Before: New MySQL tables took the server's default collation.
Now: They default to utf8mb4_unicode_ci, and MySQL refuses to compare
string columns of two collations, so a query across an old table and a new
one can fail.
Do: Keep the server's collation for new tables. This is MySQL 8's default; on MariaDB, use your server's default:
DB_COLLATION=utf8mb4_0900_ai_ci
migrate:status prints its list.
Before: migrate:status wrote its list to the log.
Now: It writes one line per migration, Applied or Pending, to the
standard output.
Do: Update scripts that read the list from the log.
Seeding
Production seeding requires force.
Before: db:seed ran in production without asking.
Now: It returns an error in production unless you pass --force.
Do: Pass --force to db:seed in production deploy scripts, to the
console binary or as suprnova db:seed --force, as Seeding
shows.
Running on a Laravel database
The shipped migrations upgrade an existing application in place.
Before: failed_jobs, jobs, job_batches, sessions,
notifications, features, roles and permissions had Suprnova's own
layouts.
Now: The shipped migrations move them into Laravel's layouts in place,
with every row. A queued, delayed or reserved job runs once, a failed job
keeps its id, a batch keeps its counts, and no user is signed out. A
migrate that stops part way resumes on the next run.
Do: An application scaffolded before 4.0.0 lists only its own
create_sessions_table migration, which keeps creating the earlier layout.
Add the framework's CreateSessionsTable to your Migrator, after your own
migrations. Without it, every request that stores a session fails with
"session persistence failed", the login page included.
Use SessionUserKey::Uuid or SessionUserKey::Ulid when your user model's
key is a UUID or a ULID.
The create migrations your Migrator already lists make the new layouts
only on an empty database. On your existing database they have already
run, so each one has an upgrade migration that moves the earlier table.
Add each upgrade migration your Migrator is missing, right after
the migration it follows:
Your Migrator lists |
Add right after it |
|---|---|
rbac::migrations::CreateRbacTables |
RbacToSpatieLayout |
notifications::migrations::CreateNotificationsTable |
NotificationTimestampsToDatetime, then NotificationsToLaravelLayout |
features::migrations::CreateFeaturesTable |
FeatureTimestampsToDatetime, then FeaturesToPennantLayout |
If you use the database queue, also add CreateJobsTable,
CreateJobBatchesTable and CreateFailedJobsTable from
queue::migrations. Registering the migrations
shows the whole list in order.
Without an upgrade migration, the earlier table stays and the framework
reads tables that don't exist. With CreateRbacTables alone, every page
that reads a user's roles fails with "no such table: model_has_roles".
Tests that start from an empty database still pass, so check the list
against a copy of your production database.
Then run the migrations. For an earlier scaffold's users, use the
migration in Running on a Laravel Database.
The RBAC, feature and notification entities follow Laravel's layouts.
Before: rbac::entity::Role and Permission had i64 ids and
display_name, the pivot entities and the
session::driver::database::sessions entity existed, and the
FeatureRow and StoredNotification timestamps were not Option.
Now: Role and Permission have u64 ids and no display_name, those
entities are gone, and the timestamps are Option. Rolling back
CreateNotificationsTable, CreateFeaturesTable or CreateRbacTables
leaves the shared tables.
Do: Read the ids as u64 and the timestamps as Option, drop
display_name, and query sessions with the query builder.
Eloquent
Typed factory counts.
Before: count(n) left make and create returning one model;
make_many and create_many built the counted set.
Now: After count or times, make returns a vector and create
returns Result<Vec<M>, FrameworkError>. Without a count, both return one
model.
Do: Where code read one model from .count(n).create(), drop the count
or take the model from the vector.
Collection metadata belongs to the collection.
Before: A JSON:API collection merged the first item's metadata into its root.
Now: A collection keeps its own document metadata at the root.
Do: Nothing to do. A client that read item metadata from the collection's root reads it from the item.
Model ordering follows timestamp declarations.
Before: oldest and latest ordered by created_at.
Now: They order by the model's declared creation column, renamed timestamps included.
Do: Nothing to do.
Force destruction includes trashed rows.
Before: force_destroy left matching soft-deleted rows in place.
Now: It physically removes them too, and counts each existing key once.
Do: Check calls that relied on trashed rows surviving.
Model visibility applies to serde and appends.
Before: Serde output did not share the hidden and visible lists, and appended accessors were not filtered by them.
Now: Array, JSON and serde outputs share the hidden and visible lists, appended accessors included.
Do: Check serde output of models with hidden fields: hidden fields are gone from it.
A clean model save skips the update.
Before: save ran the update and fired Updating and Updated even
when nothing had changed.
Now: Dirtiness comes from the instance's original values in memory, and
a clean save skips the update. Saving and Saved still fire; Updating
and Updated fire only for a dirty save.
Do: Move work that must run on every save from Updating or Updated
observers to Saving or Saved.
Pivot sync returns the changed ids.
Before: sync and sync_without_detaching returned ().
Now: They return SyncChanges, with the attached, detached and updated
id lists.
Do: Where a function returned the call's result as its own, end with
Ok(()):
post.tags.sync.await?; // the result is SyncChanges
Ok
Pivot sync updates per-id columns.
Before: Both methods took ids alone.
Now: They also take (id, Attrs) records and update an existing row
only when its supplied columns change.
Do: Nothing to do.
Pivot filters constrain sync.
Before: Both methods refused a relation with where_pivot filters: they
returned an error and wrote nothing.
Now: They honour where_pivot and its companions, and rows outside the
filter are untouched.
Do: Nothing to do. Where you dropped a relation's where_pivot filters
to sync it, you can keep them now.
Pivot sync touches a configured parent.
Before: Pivot sync did not touch the parent.
Now: A relation's .touch_parent() or a related model's inverse touch
declaration updates the parent timestamp, in the same transaction as the
pivot changes.
Do: Nothing to do.
The naive date-time casts read MySQL TIMESTAMP columns.
Before: The naive date-time casts did not read MySQL TIMESTAMP
columns.
Now: They read them.
Do: Nothing to do.
has and its kin read a polymorphic owner relation.
Before: has, or_has, has_count, doesnt_have and
or_doesnt_have on a MorphTo rendered a probe that is always false, so
has matched no row and doesnt_have matched every row.
Now: They ask every owner type the relation declares. has_count
refuses an operator other than =, <>, !=, <, <=, > and >=.
Do: Check queries that relied on the old results; they now match owners.
Testing
Console test assertions check every expectation.
Before: ConsoleRun::assert_successful checked the exit code alone, so
it passed with a prepared question the command did not ask. An unexpected
question made ask return an error, which failed the run only when the
command passed the error on. 3.2.1 had no output expectations.
Now: Each of these fails assert_successful, and an unexpected question
also fails assert_failed and the exit-code assertions, naming it.
unmet_expectations() and unexpected_questions() list them.
Do: Fix the tests that now fail: remove expectations the command never meets, and prepare every question it asks.
Queue assertions filter unchained jobs.
Before: assert_pushed_without_chain::<J>() took no argument.
Now: It takes a closure, and a matching job must also carry no chain.
Do:
; // was assert_pushed_without_chain::<SendInvoice>()
TestResponse::assert_inertia() reads a first visit.
Before: assert_inertia() refused any response without
X-Inertia: true.
Now: It also reads the page from a first visit's data-page element,
and assert_inertia_with(callback) runs a callback over the page.
Do: Nothing to do.
queue::testing::pushed::<J>() fails on a payload that does not decode.
Before: queue::testing::pushed::<J>() left out a push it could not
decode, so a test that read the list, or called assert_pushed, saw no such
push. 3.2.1 had no assert_not_pushed.
Now: It panics, naming the job and the decode error.
Do: Fix the job type or its payload so every push decodes.
Queue::reserved_jobs under Queue::fake() answers the fake.
Before: Queue::reserved_jobs under Queue::fake() read the installed
driver.
Now: It lists the reservations workers made from the fake.
Do: Nothing to do.
Frontend and Inertia responses
Development pages load an existing build when no hot file exists.
Before: In development, the Vite tags pointed at the dev server.
Now: They point at the dev server only while the hot file exists, and at
the manifest's files when it does not and a manifest exists. A frontend
that declares no @inertiajs/vite and holds an earlier build's manifest
loads that build.
Do: Declare @inertiajs/vite, as every starter kit does, or delete the
stale build.
Prop::match_on replaces the match fields on each call
Before: .match_on("x").match_on("y") added to the fields and emitted
matchPropsOn: ["posts.x", "posts.y"].
Now: Each call replaces the fields, so the chain emits ["posts.y"].
Do: Name several fields in one call:
.match_on
until on a once prop reads an integer as seconds from now
Before: .until(60) read an absolute timestamp in milliseconds and
emitted expiresAt: 60, and fresh() took no argument.
Now: An integer is seconds from the render, so .until(60) emits the
render time plus 60 seconds. fresh on Prop and OnceOptions takes a
bool.
Do: Pass a DateTime<Utc> for a fixed deadline, and write
.fresh(true):
.until // a DateTime<Utc>; was a timestamp in milliseconds
.fresh // was .fresh()
Scroll props merge under the wrapper data by default
Before: .scroll("posts", metadata, paginator) emitted
mergeProps: ["posts"], and a wrapped prop prefixed its match_on path
with the wrapper.
Now: A scroll prop merges under its wrapper (["posts.data"]), and its
match_on path is relative to the prop. .paginate and Inertia::paginate
still merge at the root.
Do: Write match_on paths from the prop: .match_on("id") on a wrapped
scroll prop becomes .match_on("data.id"). For another bare list, call
Prop::scroll_at_root().
sharedProps lists errors, and a setting turns the list off.
Before: A page with nothing else shared carried no sharedProps.
Now: sharedProps starts with errors on every response.
Do: Nothing to do. To leave the list out of the page object, call
.expose_shared_props(false) on your InertiaConfig.
X-Inertia counts as an Inertia visit for any value PHP reads as true.
Before: Only X-Inertia: true made a request an Inertia visit.
Now: Every value but an empty one and 0 does, for
Request::is_inertia, the InertiaRequestExt default, every middleware and
the development error page.
Do: Nothing to do.
The Inertia asset version follows Laravel's order and is empty by default.
Before: With no Vite manifest to hash, the page carried the version
1.0.
Now: The version comes from InertiaConfig::asset_url (ASSET_URL by
default), then the manifest's hash, then the empty string. An explicit
.version(...) or .version_with(...) still wins.
Do: Nothing to do. A client that holds 1.0 reloads once.
The asset-version 409 names an absolute URL and the current version.
Before: A stale Inertia GET got a 409 with a root-relative
X-Inertia-Location and no version.
Now: The 409 carries the request's absolute URL and
X-Inertia-Version, and InertiaResponse::version_conflict(url) adds
X-Inertia-Version too.
Do: Nothing to do.
Partial reload headers are parsed as Laravel parses them.
Before: List entries were trimmed, and an empty
X-Inertia-Partial-Data matched nothing, so the client got no props.
Now: Lists split on , with empty segments dropped and nothing trimmed,
and a header that names nothing counts as absent. X-Inertia-Reset and
X-Inertia-Except-Once-Props follow the same rule.
Do: Send these lists without a space after each comma, as the Inertia client does.
Dotted partial-reload entries narrow literal values only, as Laravel's do.
Before: only: ['users.name'] narrowed every prop's resolved value,
resolvers and defer, optional, merge and once props included.
Now: Dotted only and except entries narrow literal values only, and
the walk is Laravel's: a path that resolves to nothing yields [], a path
walks into lists by index, and a scalar a deeper path reaches ships as it
is.
Do: Nothing to do. A client that relied on a narrowed resolver value reads the whole value.
Optional and deferred props resolve on an except-only partial reload.
Before: An optional or defer prop resolved on a partial reload only
when X-Inertia-Partial-Data named it.
Now: It resolves whenever its key passes the only and except lists, a
reload with except alone included. A standard visit still leaves it out.
Do: Nothing to do.
merge and once instructions follow Laravel's partial-reload rule.
Before: Any only entry that selected a prop sent its mergeProps and
onceProps instructions, and a held .merge().once() prop still sent a
mergeProps entry.
Now: The instructions ship only when an only entry names the prop or an
ancestor of it, and a held once prop that is not deferred sends its
onceProps entry alone.
Do: Nothing to do.
The Inertia page url carries the query as Laravel normalises it.
Before: page.url repeated the query as the request sent it.
Now: The query is parsed as PHP parses it, sorted by key and re-encoded
per RFC 3986. A url_resolver's URL is left as it returns it.
Do: Update client code and tests that compare page.url with the raw
query.
An empty Inertia response redirects back as Laravel's does.
Before: An empty 200 on an Inertia visit got a 303 to the session's
previous URL or /.
Now: It goes to the same-origin Referer, then the previous URL, then
/, with 302, and 303 only for PUT, PATCH and DELETE.
Do: Nothing to do.
A redirect with a fragment keeps it on an Inertia visit.
Before: A redirect to /page#section reached the client as a plain
302, and the fragment was lost.
Now: It arrives as 409 with X-Inertia-Redirect, except on a
prefetch.
Do: Nothing to do.
Inertia visits record the previous URL.
Before: The session middleware skipped Inertia visits, so
Redirect::back and a failed validation landed on the last full page load.
Now: An Inertia GET that matched a route records its URL, unless it is
a prefetch, a Precognition request or a partial reload of the component it
rendered.
Do: Nothing to do.
errors under X-Inertia-Error-Bag takes Laravel's shape.
Before: With the header, a visit with no errors got {"<bag>": {}}, and
a session default bag was dropped.
Now: No errors give {}, a default bag gives
{"<bag>": <default bag>}, and named bags come as they are.
Do: Update client code that read the empty bag object.
Inertia flash data lives in the session until a page shows it.
Before: App::flash kept its value in the request, so a plain JSON
answer or a second redirect lost it.
Now: App::flash, Inertia::flash and InertiaResponse::flash write
the session entry inertia.flash_data. The next Inertia page emits it under
page.flash and removes it, and every redirect keeps it.
Do: Nothing to do.
clearHistory and preserveFragment survive any number of redirects.
Before: App::clear_history() and Redirect::preserve_fragment()
flashed a flag for one request, so a second redirect lost it.
Now: The flags are the session entries inertia.clear_history and
inertia.preserve_fragment, which last until a page emits them.
Do: Nothing to do.
A dotted shared key nests when it is shared.
Before: App::inertia_share stored user.age as a literal key and
nested it at render, so a later share("user", ...) kept age.
Now: A dotted key nests at share time, as Laravel's Arr::set does, so
a later share("user", ...) replaces user whole.
Do: Share a dotted key after the parent it extends.
InertiaResponse::location answers a hard navigation with a redirect.
Before: InertiaResponse::location always answered 409 with
X-Inertia-Location, which a plain browser visit cannot follow.
Now: It answers an Inertia visit with the 409 and any other request
with a 302 and Location. location_for(&req, url) stays for code outside
a dispatched request.
Do: Nothing to do.
A handler panic reaches the Inertia error page.
Before: A panic unwound past the error-page middleware, so an Inertia
visit got the JSON 500 and the client's plain-JSON modal.
Now: The same 500 reaches the error callback, and with
error_page("Error") the visit gets the Error page. Inertia::install
always registers the error-page middleware now; 3.2.1 registered it only
when error_page was set.
Do: Nothing to do.
Inertia server-side rendering
SSR is on by default, behind bundle detection.
Before: SsrConfig::enabled defaulted to false.
Now: It defaults to true, with the worker at http://127.0.0.1:13714,
and a first visit goes to the worker only when a bundle is found at
ssr_bundle_path or one of six conventional paths, such as
frontend/bootstrap/ssr/ssr.js. When none is found, the visit renders on
the client and nothing is logged.
Do: If you run SSR and your bundle is built anywhere else, name it with
.ssr_bundle_path(...) on your InertiaConfig, or call
.ssr_ensure_bundle_exists(false) when the worker's bundle isn't on this
process's disk. Without one of them, SSR stops after the upgrade and every
first visit renders on the client. To keep SSR off when a bundle exists,
call .ssr_disabled().
A missing SSR bundle is no longer logged.
Before: With SSR on and a bundle_path set but no file there, each
first visit printed "SSR bundle not found" on the standard error, or passed
that message to on_ssr_error when one was set.
Now: The visit renders on the client quietly, and ssr:start reports a
missing bundle. Under ssr_throw_on_error, a worker failure fails with
Laravel's SsrException text.
Do: Nothing to do.
SSR exclusion patterns match as Laravel's ExcludesPaths does.
Before: ssr_exclude matched the raw path, with * stopping at /, so
admin/* excluded nothing.
Now: Slashes at either end of a pattern are ignored, * matches /
too, the path is decoded first, and each pattern is also tried against the
full URL.
Do: Review your patterns: a * now matches deeper paths.
suprnova ssr:start makes Laravel's checks before it starts a worker.
Before: suprnova ssr:start checked that the bundle file existed, then
ran the runtime on it, whatever the application's SSR configuration said.
Now: It runs the application binary's ssr:start, which reads the
installed Inertia configuration, checks the bundle and the runtime, refuses
to start beside a running worker, and forwards signals to the worker.
Do: Configure SSR in InertiaConfig. The entry on the CLI's SSR
commands below shows the settings.
suprnova ssr:check asks the SSR gateway's health check.
Before: suprnova ssr:check probed /health with its own client, its
own messages and its own exit codes.
Now: It asks the installed SSR gateway's health check, prints Laravel's messages, and exits 0 or 1.
Do: Update scripts that read the old messages or exit code 2.
The CLI's ssr:start, ssr:stop and ssr:check run the application binary, and the CLI does not link the framework crate.
Before: The CLI read --bundle, --url, --timeout-ms and the
SUPRNOVA_SSR_URL, SUPRNOVA_SSR_RUNTIME and SUPRNOVA_SSR_BUNDLE
variables.
Now: Each command runs the application binary, which reads its
InertiaConfig; the flags and the variables are gone. --runtime and
--graceful pass through.
Do: Move the settings into InertiaConfig:
new
.ssr
.ssr_bundle_path
.ssr_runtime
.ssr_timeout
Inertia DevTools
Inertia DevTools recording is opt-in.
Before: 3.2.1 had no Inertia DevTools: nothing recorded requests, and no entries endpoint existed.
Now: Inertia DevTools records and serves its entries only for an
application that passes .devtools(DevToolsConfig::new()) to its
InertiaConfig; without the call nothing is recorded and no entries are
served. A configured instance records when APP_ENV names local, or as
its enabled(..) says.
Do: Nothing to do. To record, add the call to the InertiaConfig you
pass to Inertia::install, as Inertia DevTools
shows:
.devtools
TypeScript types
The starter kits declare @inertiajs/core and read root through the generated types.
Before: A kit reached @inertiajs/core only through its adapter, and
the generated inertia-props.ts did not augment it. 3.2.1 had no root
prop.
Now: Each kit's package.json declares @inertiajs/core at ^3.8.0,
and the generated inertia-props.ts augments it, so a bare usePage()
types root. A hand-written declare module '@inertiajs/core' block under
frontend/src turns the generated augmentation off, with a notice.
Do: Declare @inertiajs/core at the kits' range, delete any
hand-written augmentation block, and regenerate from the application root,
where suprnova generate-types reads Cargo.toml:
( && )
CLI and scaffolding
The project file is suprnova.toml, all lowercase.
Before: suprnova serve read its extra dev processes from the file
Suprnova.toml that 4.0.0 asks you to rename; live:add read no project
file.
Now: They read suprnova.toml, and refuse to run while a
Suprnova.toml exists, naming the rename.
Do:
suprnova new ships a root template in place of frontend/index.html.
Before: A new project had a frontend/index.html the server never
served, and every first visit was the framework's own document, titled
Suprnova.
Now: A new project has templates/app.html, declared as AppDocument
with #[inertia_root] in src/bootstrap.rs, and the docker:init
Dockerfile copies templates/ into the build stage.
Do: Nothing to do. To render first visits through your own document, add
a root template as Inertia Responses
describes, and copy templates/ in your Dockerfile's build stage.
The starter keeps an existing notes table.
Before: 3.2.1's starter had no notes migration.
Now: It checks for the table first, as the users migration does.
Do: Nothing to do.
Generated middleware passes the request on.
Before: make:middleware wrote a handle that printed request timing.
Now: It writes a handle that returns next(request).await.
Do: Nothing to do.
Fixed entries that change behaviour
These ### Fixed entries change what an application observes, so they
appear here in the same shape.
The full-stack Dockerfile copies the localization catalogs.
Before: The Dockerfile 3.2.1's docker:init wrote for a Svelte, React
or Vue project copied the binary, the schema dumps and public/ into the
runtime image and not lang/, so a container started without the
catalogs Localization::bootstrap reads at boot.
Now: The generated Dockerfile's runtime stage copies lang/ beside
public/.
Do: In a Dockerfile docker:init wrote before 4.0.0, add
COPY lang/ ./lang/ to the runtime stage, after the COPY of public.
The error callback sees the error responses from outside the Inertia stack.
Before: InertiaConfig::error_page decided only the responses that
passed through the error-page middleware, which Inertia::install
registered innermost. A middleware registered above Inertia::install
that answered without calling next, such as CsrfMiddleware's 419 or
an outer rate limiter's 429, and a panic in a middleware outside it never
reached it, so an Inertia visit got the plain-JSON modal.
Now: The server runs the same decision after the whole stack for every
error response nothing inside the stack decided, so those responses become
the page too. Such a page carries the shared registry and the shared
providers, but no session data, no detected locale and none of the
middleware hooks' shares. Inertia::install registers its error-page
middleware just outside the validation redirect.
Do: Nothing to do. To build those pages inside the visitor's session
and locale, register InertiaErrorPageMiddleware yourself, before
Inertia::install and outside the middleware whose rejections it covers,
as Inertia Responses
shows.
An error page placed outside the Inertia stack follows a fragment redirect.
Before: InertiaErrorPageMiddleware passed a response carrying
X-Inertia-Location through, but not one carrying X-Inertia-Redirect.
With error_page set, a handler's InertiaResponse::redirect, a 409
with X-Inertia-Redirect and an empty body, was read as an error and
answered with the error page, so the client never followed the soft
redirect.
Now: An Inertia protocol response, one carrying X-Inertia,
X-Inertia-Location or X-Inertia-Redirect, is never handed to an error
callback, the default one included, wherever the middleware is registered,
so the client follows the redirect to its target, a #fragment included.
Do: Nothing to do.
A drop-in Inertia form with a file, and a GET filter form, read as the client shapes them.
Before: A form request answered 415 for a multipart/form-data body.
Every reader matched names letter for letter, apart from a trailing []
for a list, so filters[status]=x arrived as one literal key, not as the
member status of filters.
Now: A form request and Request::input read a multipart body, and an
UploadedFile field takes a file part and runs its validators. Bracketed
and indexed names in a url-encoded body, a multipart body and a query
string decode into nested data as PHP's parse_str reads them, up to 64
levels deep: user[name] is the member name of user, and
photos[1]=b&photos[0]=a is a list in index order. A field that fails is
named by its dotted path, such as user.name or photos.1. A multipart
body stays under the form request's max_body_bytes, 8 MiB by default, and
1,000 parts. query_params and query_param still read each name as sent.
Do: Read nested names into a nested struct or a list. A field renamed to
a literal bracketed name, such as #[serde(rename = "filters[status]")],
no longer matches. Update tests that expected 415 for a multipart form
request.
JPEGs from the built-in driver show the right colours everywhere.
Before: The oxideav driver wrote RGB samples behind a JFIF header, so
libjpeg-based decoders, ImageMagick and most browsers among them, showed
the wrong colours.
Now: It writes standard JFIF YCbCr, every component at full resolution (4:4:4), so every decoder shows the colours the image held. The file's header changes with the samples.
Do: Nothing to do. Encode again from their sources the JPEGs the driver wrote on 3.2.1 where their colours matter, and update tests that compare the encoded bytes.
Redirects stay on the origin for a request-target that names another host.
Before: A request such as GET //evil.example/x or GET /\evil.example
that reached a fallback route had a current URL that a browser reads as
another host. Redirect::refresh_for sent the browser there,
Redirect::guest stored it as the intended URL, Redirect::intended
followed it after sign-in, and url::full returned //evil.example/x as
it was.
Now: refresh_for and guest check the request's URL with the rule
the previous URL follows: for such a request refresh_for redirects to the
root, and guest stores no intended URL and removes one already stored.
intended checks the stored value when it reads it, so such a value stored
on 3.2.1 is not followed, while an absolute URL the application stored
itself still is. url::full stays on the APP_URL origin.
Do: Nothing to do.
Queue and failed-job table names with capitals or reserved words work.
Before: The jobs driver and the failed-jobs store wrote
QUEUE_DB_TABLE and QUEUE_FAILED_DB_TABLE into their SQL unquoted.
Postgres folded a name with capitals to lower case, so it missed a table
created under its exact name, and a reserved word such as order failed as
SQL.
Now: Both quote each part of the configured name, as the shipped migrations do, so the name is the table's exact name on every database.
Do: Nothing to do for a lower-case name. On Postgres, if a name with capitals worked on 3.2.1 because its table was created without quotes, set the variable to the lower-case name the table has.
generate-types reads {post:slug}
Before: suprnova generate-types --routes looked for a handler's form
request only in its first argument, so a handler that took a bound model or
a path value first, such as update(post: posts::Model, form: UpdatePost),
got a route helper with no data argument.
Now: It skips every argument named after a path parameter and takes the
first other argument whose type is a form request, so that helper takes
data: UpdatePost. A {post:slug} segment is the parameter post.
Do: Regenerate the helpers and pass the form data to each helper that now takes it:
A prop holding <!--<script> no longer breaks the first Inertia page.
Before: The first visit's page JSON, in its
<script type="application/json" data-page="app"> element, had / escaped
but < and > as sent. A prop such as <!--<script> kept the real
</script> from closing the element, so the mount element became script
text and the client could not start.
Now: < and > are written as \u003c and \u003e beside the /
escaping, as Laravel's JSON_HEX_TAG does. The client reads the same
values.
Do: Nothing to do. A test that searches the first visit's HTML for a
prop holding < or > searches for the escaped form.
An empty SSR answer renders on the client.
Before: A worker answer of {}, or an object without body, was
inlined as an empty body, so the first visit's document had neither the
page data nor the mount element and the client could not start. An answer
of null, false, another value that is not an object, or bytes that are
not JSON, was a failure: the visit rendered on the client and
on_ssr_error fired, or it answered 500 under ssr_throw_on_error(true).
Now: Each of those answers, and any answer without a non-empty body
string, renders on the client from the page data, as Laravel's does, and is
not reported as a failure.
Do: Nothing to do. A test that expected on_ssr_error, or a 500 under
ssr_throw_on_error, for such an answer gets neither.
SSR workers at https URLs are reached.
Before: The SSR client spoke plain HTTP only and refused an https URL
in .ssr(...), so the worker was never contacted: every first visit
rendered on the client and on_ssr_error fired, or the visit answered
500 under ssr_throw_on_error(true).
Now: The client connects over TLS through rustls, as the HTTP client facade
does, and verifies the worker's certificate against the platform's trust
store. The timeout, the response cap and the refusal of an oversized
Content-Length are unchanged.
Do: Nothing to do. If your SSR URL is https, first visits now render
on the worker, so check that it renders your pages before you deploy.
A DevTools entry and Request::route_name name the route that matched by its method.
Before: Request::route_name looked the name up by the matched pattern
alone, so GET /users named users.index and POST /users named
users.store both reported whichever name was listed first, and an unnamed
PUT /users reported one of them too. Request::route_is read the same
name.
Now: The router records each name against its method and pattern, and
the server sets the matched route's name on the request, so each method
reports its own and an unnamed route reports None. route_is and the
DevTools entry follow it. A request that never went through dispatch, such
as one a test builds with with_route_pattern, still falls back to the
pattern lookup.
Do: Nothing to do. A route_is check on a path that several methods
share now matches only the method whose route carries the name.
Request::full_url and url::full normalise their query as Laravel's fullUrl does.
Before: Request::full_url and url::full returned the query as it
arrived.
Now: They sort the pairs by key and re-encode them per RFC 3986
(?b=2&a=1+x gives ?a=1%20x&b=2), with no ? when the query normalises
to nothing. The previous URL the session and Inertia record is spelled the
same way. query(), uri(), full_url_with_query and signed URL checks
keep the query as sent.
Do: Update code and tests that compare, store or log the full URL as a string.
An Inertia visit the fallback route answers records the previous URL.
Before: An Inertia GET that the fallback route answered left the
session's previous URL where it was.
Now: It records the previous URL. Request::is_fallback_route marks
such a request, which still has no route pattern and no route name.
Do: Nothing to do.
#[derive(MultipartRequest)] reads indexed and bracketed part names.
Before: The derive matched part names letter for letter, so photos[0]
never reached a photos[] field, and photos[N] names got around
max_count.
Now: A part goes to the field its name addresses, as a form request
reads it: photos[0] and photos[] fill a list declared photos[] or
photos, and user[name] fills user.name. A failure is keyed by the
sent index (photos.3), and max_count and the streaming file checks count
every part the field takes. A later parent part replaces what a field held,
and a member part such as photos[0][caption] makes the element an array,
which fails as the wrong kind.
Do: Update clients and tests that relied on literal names or on the old error keys.
A queue driver installed in the bootstrap survives the boot.
Before: queue::bootstrap_from_env replaced a driver that
Queue::set_driver installed in the bootstrap with the one QUEUE_DRIVER
names.
Now: It keeps the installed driver and does not read QUEUE_DRIVER
then. An application with a leftover set_driver call switches drivers on
upgrade, with no error.
Do: Remove a Queue::set_driver call from the bootstrap when
QUEUE_DRIVER should choose. A test that boots the queue from the
environment after a set_driver call empties the slot first:
forget_driver;
A console command whose boot fails is reported.
Before: 3.2.1 had no Exceptions. A console command whose bootstrap
panicked, or whose framework boot returned an error, printed the failure
and nothing else saw it.
Now: The failure is reported, so the callbacks the bootstrap registered before it failed see it, then printed. The application binary's own commands report their errors too.
Do: Check that your reportable callbacks expect boot failures.
cursor_paginate keeps the query's order.
Before: cursor_paginate replaced every ordering with the primary key,
ascending, so a newest-first query such as latest() came back oldest
first.
Now: Every ordering is kept and carried in the cursor. An ordering the
cursor cannot carry, such as order_by_raw, and an ordering on a joined
table's column fail with a 400 error. A joined query quotes the columns it
orders by, so an unquoted select alias with capitals no longer matches on
Postgres.
Do: Order cursor-paginated queries by columns of the model's table, or
use paginate for raw and joined orderings.
where_relation on a MorphTo relation matches the owners.
Before: where_relation, where_relation_op and or_where_relation on
a MorphTo relation matched no row.
Now: They match through every owner type the relation declares, under each owner's own scopes.
Do: Check queries that relied on the empty result.
#[model(timestamps = true)] without the columns fails the build.
Before: An explicit timestamps = true, or the bare timestamps flag,
on a struct with neither created_at nor updated_at compiled and managed
no timestamps.
Now: It is a compile error that names both columns.
Do: Add the columns to the struct, or remove the flag.
A database session read keeps a session another request refreshed.
Before: DatabaseSessionDriver::read deleted an expired row by id
alone, so a request that refreshed the same session in between lost its
write.
Now: The delete matches the row only while its last_activity is still
older than the lifetime. The read still answers no session.
Do: Nothing to do.
The Inertia facade shares a once prop.
Before: Only App::inertia_share_once and InertiaRegistry::share_once
registered a shared once prop.
Now: Inertia::share_once(key, resolver) makes the same registration
and returns the same SharedOnceProp.
Do: Nothing to do.
suprnova generate-types pairs a page built with the builder.
Before: generate-types wrote one interface per
#[derive(InertiaProps)] struct and no Pages map, and a field a handler
deferred or marked optional was written as required.
Now: A chain that starts with InertiaResponse::new,
Inertia::paginate, Inertia::data or Inertia::try_data pairs its
component with the struct .with_data(..) gives, and a key passed to
.defer(..), .defer_with(..) or .optional(..) after it is optional.
Do: Regenerate the types and fix the TypeScript errors they report:
A CLI built between two releases pins the framework commit it was built from.
Before: suprnova new pinned tag = "v<CLI version>" even when the CLI
was built from a later commit, so such a scaffold could fail cargo check.
Now: It pins the tag only when the tag names the commit the CLI was
built from, and rev = "<commit hash>" otherwise.
Do: In a project scaffolded with a rev, replace it with the release
tag when you move to a release:
suprnova = { git = "https://github.com/eas4ai/suprnova.git", tag = "v4.5.1" }
Web Push refuses endpoints that resolve to private or local addresses.
Before: EndpointPolicy::Strict checked the shape of a subscription
endpoint: https, a host name in place of an IP address, and a list of
local and cloud metadata names. It did not check where the name resolved,
so an endpoint whose name resolved to 127.0.0.1 or 10.0.0.5 was sent
to, and the transport looked the name up again when it connected. The
transport WebPushClient::new and WebPushClient::with_client_builder
built sent through a proxy set on the builder or named by HTTPS_PROXY,
and a client passed to WebPushClient::with_client sent under Strict
once allow_unconfined_redirects was called.
WebPushError::PushServiceRejected carried up to 8 KiB of the push
service's response body in its body field, and WebPushChannel put
that text in its error.
Now: The strict policy resolves the name and refuses the send with
WebPushError::RefusedDestination(AddressClass) when any answer is a
loopback, private, link-local, multicast, unspecified, documentation,
reserved or cloud metadata address. A failed lookup returns
WebPushError::Lookup, which is retryable. The transport that
WebPushClient::new and WebPushClient::with_client_builder build
connects to an address that was checked, without a second lookup, and
with_client_builder replaces the builder's DNS resolver to do it. That
transport sends through no proxy: a proxy set on the builder is dropped,
and HTTPS_PROXY and the other proxy variables are not read. A strict
send over a client passed to with_client, which resolves the name
itself, returns WebPushError::UnpinnedTransport before anything is sent
until you call allow_unpinned_destinations; the endpoint and the
addresses its name resolves to are still checked, and the connection
goes where your client sends it. A name you map with
ClientBuilder::resolve connects to the address you mapped.
PushServiceRejected has no body field: the first 200 bytes of the body
go to the debug log. WebPushChannel logs a refused destination as a
subscription that cannot be used, and the dispatch succeeds; its error for
a rejection names the status alone.
Do: If your push service runs on a private network, give its client
with_endpoint_policy(EndpointPolicy::AllowAny). Endpoints at public push
services need nothing. In code that matches on WebPushError, drop body
from a PushServiceRejected pattern, add arms for RefusedDestination,
Lookup, UnpinnedTransport and OriginNotAllowed to an exhaustive
match, and remove a subscription that returns RefusedDestination, as
you remove one that returns InvalidSubscription. An exhaustive match
on EndpointPolicy adds an arm for Allowlist. If you set a DNS resolver on the builder
you pass to with_client_builder, it is replaced; map a fixed address
with ClientBuilder::resolve instead. If you pass a client to
with_client under Strict, move to with_client_builder, or add
.allow_unpinned_destinations() to keep that client. If your push
service is reachable only through a proxy, a proxy on the builder or in
HTTPS_PROXY no longer reaches it: build a client with the proxy, pass
it to with_client, and add .allow_unconfined_redirects() and
.allow_unpinned_destinations(), or use EndpointPolicy::AllowAny when
your application cannot resolve the push service's name.
The same-origin WebSocket policy compares the scheme.
Before: OriginPolicy::SameOrigin compared the Origin host and port
with the Host header and ignored the scheme. A page at
http://app.example.com could open a socket on the application served at
https://app.example.com, and a TLS-terminating proxy needed no
configuration for the check to pass.
Now: It compares the Origin scheme, host and port with the request's
public origin: the scheme Request::scheme reports, which reads
X-Forwarded-Proto and X-Forwarded-Ssl from a trusted proxy only, the
host Request::host reports, and the port Request::port reports or the
scheme's default. It refuses with 403 an upgrade whose public scheme it
cannot determine: a trusted proxy that sends neither header or names a
scheme other than http or https, or either header from a peer that is
not a trusted proxy. A malformed handshake is answered 400 before its
origin is checked.
Do: Behind a TLS-terminating proxy, list the proxy in
APP_TRUSTED_PROXIES and make sure it sends X-Forwarded-Proto, as
proxy_set_header X-Forwarded-Proto $scheme; does in nginx, or give the
route an OriginPolicy::AllowList of its public origins. An application
whose browsers reach it with no proxy in between has nothing to do. See
Same-origin behind a proxy.
Static files and Live UI assets are read through the directory they were checked in.
Before: 3.2.1's StaticFiles checked a request's canonical path against
its root and then opened the file by name, and the routes of
try_live_ui_assets() and try_live_ui_assets_for() compared canonical
paths and then read the file's size and bytes by name. A
directory swapped for a symbolic link between the check and the open made
either one send a file from outside its root.
Now: Both walk the path one directory at a time through held directory
handles, with O_NOFOLLOW on every entry, and take the type, the length
and the 1 MiB bound from the handle they read. StaticFiles still follows
a link that stays inside its root, and either root may itself be a link,
since the root is resolved once per request. A link whose relative target
climbs above the root is refused even when it leads back in, and
StaticFiles answers 404 for a FIFO instead of holding the request open.
Do: Nothing to do, unless a symbolic link under your public directory
reaches its target through .. above the root: point it at the target by
a path that stays inside the root.
The magick driver names the coder it hands ImageMagick.
Before: The magick driver named the coder for PNG, JPEG, WebP, GIF
and BMP, and handed any other input to ImageMagick on a bare -.
ImageMagick then chose the coder from the bytes, so an MVG or MSL
document ran as a script, and the host's policy.xml was the only
barrier.
Now: The driver also names HEIC and the other HEIF images, AVIF,
TIFF and BigTIFF, PSD and PSB, and reads each with its own coder. It
refuses any other input before ImageMagick starts, with a 4xx
FrameworkError that says the format is not one Suprnova names.
ImageConfig has a magick_autodetect field, off by default, that
IMAGE_MAGICK_AUTODETECT sets.
Do: Nothing to do unless your application reads, through ImageMagick, a format outside that list, such as PPM or JPEG XL. To keep reading it, opt into autodetection. Such input then reaches ImageMagick under the framework's policy:
IMAGE_MAGICK_AUTODETECT=true
A request body that stops arriving is abandoned.
Before: The server bounded only the request head
(SERVER_HEADER_READ_TIMEOUT). A read of the request body waited for each
piece with no deadline, so a client that sent complete headers and then
sent the body a byte at a time, or stopped sending it, held the connection
for as long as it liked.
Now: Every read of a request body waits at most
SERVER_BODY_READ_TIMEOUT (default 30 seconds) for the next piece, and at
most SERVER_BODY_TOTAL_TIMEOUT (default 3600 seconds) for the whole body,
counted from when the server read the request head. That covers the
framework's reads and a handler that takes the raw stream with
Request::into_parts and reads it itself. A body that misses either
deadline is abandoned: the read fails with 408 Request Timeout, the
response carries Connection: close, and the server closes the
connection. A body nothing reads, such as one a handler keeps while it
waits on other work, is abandoned at the total deadline and its connection
closed, without a response. ServerConfig has the fields
body_read_timeout and body_total_timeout. BodyState::Streaming and
BodyState::Partial hold a suprnova::http::TimedBody, the body under its
deadlines, where they held hyper::body::Incoming.
Do: An application that streams uploads taking longer than an hour
raises SERVER_BODY_TOTAL_TIMEOUT, and one whose clients pause for more
than 30 seconds in the middle of a body raises SERVER_BODY_READ_TIMEOUT.
A struct literal that builds a ServerConfig names the two fields;
ServerConfig::builder() fills them. A handler that matches
BodyState::Streaming or BodyState::Partial and names the body's type
names suprnova::http::TimedBody in place of hyper::body::Incoming. It
reads the same frames through http_body_util::BodyExt; a frame's error is
a FrameworkError instead of a hyper::Error, so ? returns it from a
handler, and collect_body_with_cap collects it as before. A handler that
does other work before it reads the body finishes reading it within the
total deadline. For details, see
Body read deadlines.
New projects install the timeout middleware.
Before: No bootstrap suprnova new wrote installed TimeoutMiddleware,
so in a new project a slow handler or a hung query held its connection
until the client gave up.
Now: Every bootstrap installs TimeoutMiddleware::default(), a
30-second ceiling on the time to a response that answers 503.
Do: A project created with 3.2.1 keeps its bootstrap. To get the same
ceiling, add the line to register_http_stack in src/bootstrap.rs, after
Inertia::install and before CsrfMiddleware:
global_middleware!;
An API project adds it to register, before BearerTokenMiddleware. A
route that takes uploads longer than 30 seconds needs a longer value; see
Request Timeouts.
A password reset whose revocation fails queues a retry.
Before: When the provider path of PasswordReset::complete or
complete_with_outcome could not revoke the user's other sessions or
remember-me tokens, it logged a warning, reported the error in the outcome,
and nothing tried again. A stolen session or remember-me cookie kept working
after the reset.
Now: Both push a RevokeUserCredentials job for the user with
Queue::push before they return, and a queue worker retries both
revocations until both stores confirm them, with no bound on the tries and
up to an hour between tries. The framework registers the job with the
worker. PasswordResetOutcome has a revocation_retry field of type
RevocationRetry: Queued, or NotNeeded when both revocations
succeeded. When the push fails too, both return an error in place of the
outcome, logged at error level with the user's id: the password has
changed, and that reset sends no PasswordChangedMail and dispatches no
PasswordResetCompleted.
Do: Run queue:work on a driver that keeps jobs, such as database or
redis, so the retry runs: the memory driver loses the job on a restart,
the null driver discards it, and the sync driver runs it once inside the
push. A worker started with --tries gives the job that bound, so after a
long outage the job lands in failed_jobs; run queue:retry on it then.
If your handler reads the outcome, log the queued retry with the user's id.
Answer the error a failed push returns as any other 500; its message
names the user and the job to queue once the queue works,
RevokeUserCredentials::new("<id>"). A PasswordResetOutcome you build
yourself, for example in a test, needs the new field, set to one of its two
variants. For details, see
When a revocation fails.
A session or remember-me cookie from before a password change stops working.
Before: A password reset ended the user's other sessions and revoked
their remember-me tokens at once. Nothing checked a session or a remember-me
cookie against the password afterwards, so when the session store or the
remember_tokens table refused the revocation, a stolen session or cookie
kept working until it expired.
Now: Every sign-in issued through a user provider records the user's
auth epoch: the value Authenticatable::auth_epoch returns, or a digest of
the password hash get_auth_password returns. Every request that resolves
the user compares it with the current one, and on a mismatch ends that
guard's sign-in and continues as a guest's. Two consequences follow for a
3.2.1 deployment. A remember-me cookie issued before 4.0.0 was hashed
without an epoch, so under the new check it fails verification once, its
row is deleted, and the cookie is cleared: every remembered device signs in
again one time after the upgrade, and no data change is needed. A session
signed in before the upgrade carries no epoch, so it is not checked until
that user signs in again. A user whose model returns None from both
auth_epoch and get_auth_password records no epoch and is not checked.
A sign-in through Auth::login_id, session::set_auth_user, or
TwoFactor::complete_challenge reads the epoch through the default guard's
provider in the request that signs in, before the session is stored; when
the provider fails that read, the sign-in is not stored and a warning is
logged. BasicAuthMiddleware::new() lets a signed-in session pass without
an Authorization header only after the guard has resolved the user and
compared the epoch, so a session from before a password change gets the
401 challenge. Each remember-me credential is bound to the epoch the
guard's provider read for the sign-in. A SessionGuard that you build
yourself under a name the AuthManager doesn't know issues none: its login
completes without a remembered session and logs a warning that names the
guard.
Do: Nothing to run, for most applications: no migration and no
configuration. Expect each remembered device to sign in once after the
deploy. A form that changes the signed-in user's password through the
model keeps that user's own session: the request that resolved the user
and saved the new hash into the password column binds its sign-in to the
new epoch once the write commits, and every other session and remember-me
cookie of that user stops working. A user provider of your own implements
UserProvider::written_user, a downcast to its model type, so the
framework recognizes the written user; a model whose hash lives in another
column names it from Authenticatable::auth_password_name; a password
written with a query-builder update or raw SQL isn't seen, and that
session ends on its next request. If your user model exposes no password hash and no epoch, its
sessions stay unchecked; return the hash from get_auth_password, or a
counter from auth_epoch, to have them checked. A custom user provider
whose model keeps a counter that advances without a password change
implements auth_epoch to have that counter checked. If you build a
SessionGuard yourself under a name the AuthManager doesn't know and
sign users in with remember-me through it, register the guard with the
AuthManager (AuthConfig::guard with GuardConfig::session, and its
provider with Auth::register_provider), or sign in through the manager's
guard; otherwise its logins complete without a remembered session and log
a warning. See Auth epochs.
The magick driver runs ImageMagick under the framework's own policy.
Before: 3.2.1's magick driver started ImageMagick under the host's
policy.xml alone. On a host with ImageMagick's default policy, which
denies nothing, PostScript or PDF on a bare - started Ghostscript, and
the MVG, MSL, URL and text coders and every delegate were open.
Now: Every run reads a policy the framework writes and checks before
the run, at .config/ImageMagick/policy.xml in a directory of its own
under the system's temporary directory. The driver starts ImageMagick
with HOME naming that directory, and with MAGICK_CONFIGURE_PATH
naming the directory that holds the file first and last, with a
MAGICK_CONFIGURE_PATH you set between the two. The policy denies the
URL, HTTP, HTTPS, FTP, MVG, MSL, TEXT, LABEL, CAPTION, PANGO, SCREENSHOT,
X, EPHEMERAL, PS, PS2, PS3, EPS, PDF and XPS coders, every delegate, and
@file reads, with or without IMAGE_MAGICK_AUTODETECT. ImageMagick
reads $HOME/.config/ImageMagick after every other policy file and
applies the last entry that matches, so the framework's policy outranks
every other policy on the host: the system policy,
$XDG_CONFIG_HOME/ImageMagick, your MAGICK_CONFIGURE_PATH, and an entry
marked stealth="true". Your own denials still apply. Before the first
run of each process, the driver checks that order: magick -list policy
must show the last entry that matches each denial denying and the
framework's file as the last one read, and one MVG probe must be refused
by ImageMagick's security policy. A check that fails refuses every run,
with or without IMAGE_MAGICK_AUTODETECT, with a 5xx FrameworkError
that names the cause and the file, until the host is fixed and the
process restarts. A listing or probe that cannot be had refuses the run,
and the next run checks again. A policy directory the driver cannot make,
or a policy file it cannot write and check, refuses the run too.
Do: Nothing to run, for most applications. A policy of your own still
applies: the service account's own ImageMagick directory
($XDG_CONFIG_HOME/ImageMagick, else $HOME/.config/ImageMagick) and the
directories on your MAGICK_CONFIGURE_PATH are read before the framework's
file, so their denials and configuration hold under its policy. If
your application reads PostScript, EPS, PDF, XPS, or another of those
formats through the magick driver under IMAGE_MAGICK_AUTODETECT, those
reads now fail with a security policy error, and nothing reopens them:
convert such files before they reach the driver. The process must be able
to make a directory in its temporary directory: in a container with a
read-only root file system, mount a writable /tmp or set TMPDIR to a
writable path. For details, see Policy.
Fixes with no upgrade step
These ### Fixed entries change nothing an application upgrading from 3.2.1
observes, or touch only the framework's own tooling and tests.
- The Laravel parity map holds the Inertia adapter and client. The parity map is the framework's own tooling.
- Images are no longer copied on their way through a driver. It removes copies inside the drivers, and counts the memory of the ICC profiles 4.0.0 reads and keeps: on 3.2.1 the built-in driver dropped a profile, and the
magickdriver left it to ImageMagick. - The Inertia CRUD tutorial compiles The fix is to the manual's tutorial, not to the framework.
- Live upload and async routes follow the configured endpoint Serving under a path prefix is new in 4.0.0, and at the host root the routes are the same
/__liveroutes. - Batch settlement counts work on MySQL It concerns a
job_batchestable Laravel created, which 4.0.0 is the first release to support. - On Postgres, a session write for a guest It concerns Laravel's
sessionslayout, new in 4.0.0; on 3.2.1sessions.user_idwas text. - A component's scripts import by relative path only. The script scan is new in 4.0.0.
- A component's script can no longer reach a built-in prototype through a computed key or a name. The script scan is new in 4.0.0.
- A destructuring assignment's defaults, nested keys and shorthand targets are scanned. The script scan is new in 4.0.0.
- The script scan follows a value by the names it was written with. The script scan is new in 4.0.0.
- The script scan sees the global object through an expression. The script scan is new in 4.0.0.
- Writing a browser global no longer unlocks reading it. The script scan is new in 4.0.0.
- Magnetar's API documentation builds without the
two-factorfeature. It changes API documentation only. - API documentation writes ranges in words. It changes API documentation only.
- An Inertia page that cannot be encoded answers an error. Encoding a page does not fail in practice, so no response changes.
- DevTools redacts a flattened key by its parts. DevTools is new in 4.0.0.
- DevTools redacts the URLs in headers and bodies. DevTools is new in 4.0.0.
- A lost DevTools index no longer drops the entries it listed. DevTools is new in 4.0.0.
- A commented-out augmentation no longer hides the generated one. The generated
@inertiajs/coreaugmentation is new in 4.0.0. - A hook-shared prop names a file and line. It changes only what DevTools records, and DevTools is new in 4.0.0.
- A page whose document fails is recorded as the error the client got. It changes only what DevTools records, and DevTools is new in 4.0.0.
- Every render prop has a
renderSource. It changes only what DevTools records, and DevTools is new in 4.0.0. - DevTools reads a request body as the handler would. DevTools is new in 4.0.0, and without it the handler reads its body as on 3.2.1.
- A DevTools JSON request body is recorded whatever it parses to. DevTools is new in 4.0.0.
- DevTools no longer reads an upload before its authorization. DevTools is new in 4.0.0.
- The DevTools id tag finds a closing body tag in any case. DevTools is new in 4.0.0.
- A tagged first visit no longer carries the untagged page's length. The DevTools id tag is new in 4.0.0.
schedule:interruptboots the cache it writes its mark to.schedule:interruptis new in 4.0.0.- Every Live engine type a public signature names is named through
suprnova. It only adds paths, so code that built on 3.2.1 is unaffected. - The Inertia DevTools gate's
GateEvaluatedevent names the signed-in user. DevTools is new in 4.0.0. simple_paginatetakes a page name, andcursor_paginaterecords its cursor. Both fields are new in 4.0.0; the struct literals are covered under Public types with new fields or variants.- What the script scan learns from member writes does not depend on statement order. The script scan is new in 4.0.0.
- The script scan refuses every write to a member of the global object. The script scan is new in 4.0.0.
- The Laravel-database tests run each SQLite test on one connection. It changes the framework's own tests.
- The component allowlist refuses test fakes, app-wide hooks and installs. A component's Rust, and the allowlist that reads it, are new in 4.0.0; a 3.2.1 component carried no Rust.
- Every Live and Magnetar type a public signature leads to is named through
suprnova. It only adds paths, so code that built on 3.2.1 is unaffected. - The dogfood application's password reset reports success only when both revocations succeed. The dogfood application is not a published surface.
- A field shows its own validation message. A field's
live:erroroutput is new in 4.0.0. - A user who changes their own password stays signed in. It is part of the section A session or remember-me cookie from before a password change stops working.
- A fresh scaffold is rustfmt-clean. It changes only the files
suprnova newwrites. - The account's own ImageMagick policy applies again. It is part of the section The
magickdriver runs ImageMagick under the framework's own policy. - The framework's frames below a Live action fit Tokio's default stack. It changes only how much stack the framework's own code uses.
- The scroll area's size token is defined. It defines the token the shared stylesheet already read; a page or region that sets its own size is unchanged.
Added entries that change behaviour
These ### Added entries change a path that 3.2.1 already had.
Third-party Live component libraries.
Before: live:add installed a component of the shipped library by
name, or a third-party one with --manifest, by copying the view,
stylesheet and script its manifest.json named under templates/<root>/,
with no scan and no question. A component carried no Rust.
Now: live:add also installs from a library's git repository by
address, from an https:// URL and from a ./path, and a component can
carry the Rust of a Live component. Before it writes anything, it scans
every view, stylesheet, script and Rust file as data, admits only what it
can classify, and shows a plan. For a third-party component it asks you on
a terminal to approve each capability the component uses, to pin the
library's key on its first install and to confirm the plan; without a
terminal it refuses unless you pass --yes, and --allow <capability>
approves a capability. A shipped component still installs with no
questions.
Do: Nothing to do for the shipped library. Run a third-party install on
a terminal, or pin the library's key in suprnova.toml yourself and pass
--yes and --allow for each capability the plan names. If the scan
refuses a component you installed on 3.2.1, its author changes it as the
refusal says. See Installing Live Components.
A timebox on reset links.
Before: PasswordReset::send_link answered as soon as its work was
done, so an unknown address answered sooner than a known one.
Now: Every answer waits until PASSWORD_RESET_TIMEBOX_MS milliseconds
(200 by default) have passed since the call began.
Do: Nothing to do. Tests that must not wait set the hold to 0:
PASSWORD_RESET_TIMEBOX_MS=0
Your own reset message.
Before: On the provider path, PasswordReset::send_link sent
PasswordResetMail itself, greeting the user by name.
Now: It sends through UserProvider::send_password_reset_notification.
EloquentUserProvider calls the model's
CanResetPassword::send_password_reset_notification, whose default mail
goes to email_for_reset() and greets the reader without a name. Another
provider's default returns an error when flow_user_by_id finds no user.
Do: To keep the name or send your own message, override
send_password_reset_notification on your user model, as
Auth Flows describes. With Magnetar installed, the framework
sends PasswordResetMail itself and greets the name Magnetar holds; the
override is never called.
Retry dates.
Before: down --retry took whole seconds.
Now: It takes seconds or a date, sends a date as Retry-After in the
RFC 7231 form, and refuses a value that is neither.
Do: Nothing to do.
Namespaced translations.
Before: The browser catalog held plain keys.
Now: Lang::add_namespace reads a package's catalogs as
namespace::key, and the browser catalog holds them as namespace__key.
Do: New kits ship no translations or browser helper. For SSR, use
Lang::get("namespace::key") in Rust and pass the text in props. If your
existing application translates in the browser, encode namespaced keys
as namespace__key in its helper before looking them up.
A Web Push endpoint allowlist.
Before: WebPushChannel::new returned the channel, which sent under
the policy its client was built with. EndpointPolicy had the variants
Strict and AllowAny and was Copy.
Now: WebPushChannel::new reads WEB_PUSH_ENDPOINT_ORIGINS and
returns Result<WebPushChannel, FrameworkError>. When the variable names
one or more origins, the channel sends under
EndpointPolicy::Allowlist with them, in place of its client's policy, and
logs an endpoint at any other origin as a subscription that cannot be
used. An entry that is not an origin makes new return an error that
names the variable. Unset or blank, the channel behaves as on 3.2.1.
EndpointPolicy has the Allowlist variant and is Clone but not Copy.
Do: Add ? to each WebPushChannel::new call, or handle its error.
Clone an EndpointPolicy you use more than once. To limit sends to the
push services your users subscribe through, set
WEB_PUSH_ENDPOINT_ORIGINS as
Web Push
describes, and leave it unset in tests that send to a mock server under
AllowAny.
Public types with new fields or variants
These public types changed shape without a changelog entry of their own.
MultipartValue::File has an image_size field.
Before: The variant had no image_size.
Now: It carries image_size: Option<(u32, u32)>, an image's width and
height read from the part.
Do: A pattern on MultipartValue::File ends with .. or names
image_size.
GroupItem has a Resource variant.
Before: A route group held routes and nested groups.
Now: It also holds a resource, as GroupItem::Resource.
Do: An exhaustive match on GroupItem adds an arm for Resource.
RelationEntry has three more fields.
Before: RelationEntry had no target_join_key,
through_soft_deletes_column or morph_targets.
Now: It has all three.
Do: Nothing to do. #[suprnova::model] fills them. A hand-built
RelationEntry names them.
UploadValidator has a validate_received method.
Before: A validator checked a file part through validate_final.
Now: validate_received also runs, with what the extractor received.
Do: Nothing to do. Its default calls validate_final.
Paginator has a page_name field.
Before: The simple Paginator had data, current_page, per_page,
has_more and path.
Now: It also has page_name: Option<String>, the query parameter the
page is read from, None for page. It is not serialized by itself.
Do: A struct literal that builds a Paginator names page_name. Code
that reads the fields is unchanged.
CursorPaginator has a current_cursor field.
Before: CursorPaginator had data, per_page, next_cursor,
prev_cursor, path and cursor_name.
Now: It also has current_cursor: Option<String>, the cursor the page
was fetched with, None for the first page. It is not serialized by
itself.
Do: A struct literal that builds a CursorPaginator names
current_cursor. Code that reads the fields is unchanged.
Next
- Changelog - every 4.0.0 entry in full
- Installation - install the CLI and pin the framework version
