Vapor 5 Migration: Preparing a Vapor 4 App Before the Guide Exists
There is no Vapor 5 migration guide yet. Vapor 5.0.0-beta.1 was tagged on September 15, 2026 and beta.2 followed a day later. As of September 29, 2026, when this was written, the Vapor team's announcement still lists the migration guide and the full documentation as forthcoming. If you run a Vapor 4 app in production, that leaves a question the docs cannot answer yet: what can you do now?
More than you might think. Put the current Vapor 4 project template next to the Vapor 5 one and most of the difference is work Vapor 4 already supports: an async entrypoint, async handlers, AsyncMiddleware, AsyncMigration and async lifecycle hooks. Finishing the async migration is the part you control. The rest (a new server, a new configuration system, a Fluent integration still being designed) you cannot prepare for, and some of it you should deliberately not guess at.
Everything below comes from sources you can read yourself: the two branches of the vapor/template repository, the Vapor team's beta posts and the Vapor source. Where the templates are all we have to go on, we say so. If you are still choosing a framework, or want the Vapor 5 roadmap and support windows, those are in our comparison rather than repeated here.
Vapor vs Hummingbird in 2026: Choosing a Server-Side Swift Framework
What the Vapor 5 migration actually changes
The Vapor team calls the betas relatively stable but not ready for critical production, and gives no release date. On GitHub, the beta.3, beta.4 and 5.0.0 milestones had 8, 13 and 21 open issues on September 29, and none had a due date. The changes that matter for a migration:
- No EventLoop and no EventLoopFuture. The public API is async/await throughout, and SwiftNIO has almost disappeared from it (ByteBuffer survives in multipart).
- A new HTTP server built on Swift HTTP Server, with HTTP/2, HTTP/3 and bidirectional streaming. The server package's own README calls it a work in progress.
- Request and response bodies stream by default. Nothing is collected for you, so decoding content needs an await.
- Application becomes a Service run by Swift Service Lifecycle, and Swift Configuration replaces the environment plumbing. Storage is gone, and dependencies such as the database are passed in explicitly rather than hung off Request.
- Swift HTTP Types appear in the public API: a handler in the Vapor 5 template returns HTTPResponse.Status.
- An experimental macro router (@Controller, @GET, @POST) in a separate VaporMacros product.
- A floor of Swift 6.4 and macOS 26.
Beta.1 alone merged 49 pull requests on top of the last alpha, including ones titled "Application Overhaul", "Request Overhaul" and "Request Body Streaming By Default". This is not a point release. But look at how much of the list hangs off the first line.
Step 1: Get the toolchain moving
Start here, because it is the one prerequisite no code change removes. Vapor 5's Package.swift declares swift-tools-version 6.4 and a macOS platform of 26.2. Swift 6.4 was released on September 15, 2026. A team whose CI runners, deploy image or laptops are on Swift 6.0–6.3 cannot resolve the beta at all.
For us that is concrete. This site builds on the swift:6.0-jammy Docker image, and all but one of its package manifests declare tools version 5.9. Getting to 6.4 is a scheduled upgrade rather than a code change, and it runs on a different calendar from your app code: base images, CI and every developer's Mac.
What you can do inside Vapor 4 today is language mode. Vapor 4 itself is on tools version 6.0, and the current Vapor 4 template on 6.3. A 6.x manifest puts your targets in Swift 6 language mode, with complete concurrency checking, and that is where the Sendable problems a rewrite like Vapor 5 will surface show up first. Both templates also turn on upcoming features. These four are in both:
// Enabled in the Vapor 4 and Vapor 5
// templates (vapor/template, main and vapor5)
swiftSettings: [
.enableUpcomingFeature("ExistentialAny"),
.enableUpcomingFeature(
"InternalImportsByDefault"),
.enableUpcomingFeature(
"MemberImportVisibility"),
.enableUpcomingFeature(
"InferIsolatedConformances"),
]The Vapor 5 template adds NonisolatedNonsendingByDefault on top. Turn them on one at a time, on a branch, and treat the diagnostics as a work list. ExistentialAny alone asks for an explicit any on every protocol used as a type in your signatures.
Step 2: Move the entrypoint to async
This is the current Vapor 4 template's entrypoint, from the main branch of vapor/template:
@main
enum Entrypoint {
static func main() async throws {
var env = try Environment.detect()
try LoggingSystem.bootstrap(from: &env)
let app = try await Application.make(env)
do {
try await configure(app)
try await app.execute()
} catch {
app.logger.report(error: error)
try? await app.asyncShutdown()
throw error
}
try await app.asyncShutdown()
}
}If yours still builds the app with Application(env) and calls app.run(), make this change. In Vapor 4's source the synchronous initialiser is marked noasync and deprecated, with a message telling you to use Application.make instead. The async lifecycle hooks in Step 5 are only called when the app was created with make, so this step unlocks that one.
Now the part most upgrade advice skips. Vapor 5 replaces all three calls. The vapor5 branch of the template creates the application from a Swift Configuration reader and runs it:
// Vapor 5 template (vapor5 branch), excerpt
let app = try await Application(
configReader: config
)
try await app.run()
try await app.shutdown()Application.make, execute() and asyncShutdown() do not survive, and neither does reading configuration through Environment: Vapor 5's default configuration reads command-line arguments first and environment variables second. What survives is the shape: an async main that owns the application's lifetime and awaits it. Adopt make now for that shape and for the hooks it enables, not in the hope that these lines will compile under Vapor 5. They will not.
vapor/template on GitHub (main is Vapor 4, vapor5 is the beta)
Step 3: Take handlers and middleware off EventLoopFuture
In an older codebase this is the bulk of the work, and it is pure Vapor 4. The Vapor docs show handlers declared async throws that return their value directly, instead of chaining flatMap and map on futures:
routes.get("firstUser") { req async throws -> String in
// await the work, return the value
}A req.eventLoop.future(value) inside such a handler becomes a plain return of the value, because the function is already async. That is our reading rather than a documented rule, but there is nothing else for it to become. Where something you do not own still returns a future, Vapor 4's documented bridge is to await it at the call site:
// Vapor 4 bridge: await a future you
// cannot change yet
let value = try await future.get()Every .get() is a call site that has to disappear eventually, so keep them as a list rather than calling the job done. Not all of them will go at once: the Vapor 5 template itself still calls .get() on FluentKit's Migrator, so futures persist below Vapor for now.
Middleware gets the same treatment. This is the AsyncMiddleware example from the Vapor middleware docs, wrapped to fit this page:
struct AddVersionHeaderMiddleware: AsyncMiddleware {
func respond(
to request: Request,
chainingTo next: AsyncResponder
) async throws -> Response {
let response = try await next.respond(
to: request
)
response.headers.add(
name: "My-App-Version", value: "v2.5.9"
)
return response
}
}Two caveats. In Vapor 4, AsyncMiddleware is a bridge: it refines the future-based Middleware protocol and wraps your async body in a promise. And we could not verify what Vapor 5's middleware protocol looks like. What you gain is async bodies that should move over with little change, not a guaranteed signature.
One thing not to read as preparation: @Sendable on handlers. The Vapor 4 template marks its route handlers @Sendable and the Vapor 5 template does not. Add it where Swift 6 mode in Vapor 4 asks for it, and expect to delete it later.
Step 4: Convert migrations to AsyncMigration
This is the easiest step, and the only one where Vapor 4 code carries over nearly as written. The shape from the Fluent migration docs:
struct CreateTodo: AsyncMigration {
func prepare(
on database: any Database
) async throws {
// ...
}
func revert(
on database: any Database
) async throws {
// ...
}
}The Vapor 5 template's CreateTodo has the same AsyncMigration shape. The only difference is the import: it imports FluentKit rather than Fluent. Run migrations with the async call:
try await app.autoMigrate()If you still have Migration types that return futures, convert them now. It is mechanical, you can check it against a scratch database, and none of it depends on a decision the Vapor team has not made yet.
Step 5: Use the async lifecycle hooks
Whatever you may have read, Vapor 4 has no AsyncLifecycleHandler. The async hooks are methods on LifecycleHandler itself: willBootAsync, didBootAsync and shutdownAsync, called only when the app was created with Application.make. This is the handler in this site's analytics package, which writes the last page-view counts on shutdown:
struct PageViewLifecycle: LifecycleHandler {
let counter: PageViewCounter
func shutdownAsync(_ application: Application) async {
await counter.shutdown()
}
}
// Registered at configure time
app.lifecycle.use(PageViewLifecycle(counter: counter))In Vapor 5, LifecycleHandler's willBoot, didBoot and shutdown are themselves async, and the template registers handlers with app.addLifecycleHandler. Moving there is a rename that drops the Async suffix. Doing the async half now means the rename is the whole job later. The services page in the Vapor docs still shows only the synchronous example, so the LifecycleHandler source on the vapor4 branch is the better reference.
What to leave alone for now
Some of the Vapor 5 diff cannot be done in Vapor 4, and some of it you should not guess at.
- Fluent and req.db. The beta template drops the Fluent package and uses FluentKit and a driver directly. It builds a Databases value, registers it with app.addService, and hands it to controllers through their initialisers. The Vapor team describes the Fluent API as still in flux, and vapor/fluent's latest release, 4.13.0 from September 2025, still depends on Vapor 4. Going async (Step 4) is safe. Rebuilding your data access around a guess at the final shape is not.
- WebSockets and compression. Neither is in the beta yet. The websocket-kit dependency is commented out in Vapor 5's Package.swift while the new server gains an implementation, and compression is marked "coming soon". There is nothing to port to.
- JWT, Leaf and Queues. JWTKit 5 is already async and Sendable-correct, and vapor/jwt 5.1 runs on Vapor 4.110 or later, so moving off an older JWT package is worth doing on its own merits. Whether vapor/jwt has a Vapor 5 line, we could not find out. Leaf is at 4.5.2 and Queues at 1.18.0, with no Vapor 5 release of either.
- Streaming bodies. We found no Vapor 4 API that gives you Vapor 5's stream-by-default behaviour, so there is nothing to rehearse. Just know that every content decode is a line you will touch.
- Macro routing. @Controller and its siblings are experimental, and the Vapor team says macro middleware is still being worked on. Do not restructure controllers to look like the macro version.
What we could not check
There is no migration guide to check this plan against, so it rests on two templates, the beta posts and the Vapor source. We could not confirm Vapor 5's middleware protocol, how req.db is meant to be replaced beyond what the template does, or anything said in Vapor's Discord. We have not built a Vapor 4 app against the beta. When the guide lands, trust it over this article.
A grep-able checklist
Run these from each package root. Every line gives you a list of call sites, not a verdict:
# Futures and event loops
grep -rnE 'EventLoopFuture|req\.eventLoop' Sources
grep -rn '\.wait()' Sources
grep -rn '\.get()' Sources
grep -rn '\.flatMap' Sources
# Future-based conformances left over
grep -rnE ': (Middleware|Migration) *[,{]' Sources
# Hooks to rename later, and the Fluent question
grep -rn 'LifecycleHandler' Sources
grep -rn 'req\.db' Sources
# Deprecated synchronous app setup
grep -rn 'Application(' SourcesWe ran the list over this site's own repository, which serves four sites from one Vapor 4 server and has separate packages for analytics, comments and recruitment. On September 29, 2026:
- EventLoopFuture, req.eventLoop and .wait(): no hits in our code.
- Every .flatMap hit was a false positive: flatMap on an optional or a collection, as in reading a port number from the environment and passing it through Int.init. Read the hits; do not count them.
- One .get(), on an HTTP client response in the job that fetches crawler IP ranges. One bridge to remove.
- One LifecycleHandler, already on shutdownAsync. A rename, later.
- 62 AsyncMiddleware and AsyncMigration conformances, and no future-based ones left.
- 284 lines mentioning req.db, 267 of them in the recruitment package. That is the Fluent question above, and by far the largest item on our list.
So for us the code migration is small, and the real work is the toolchain and Fluent. Your list may come out the other way round. A codebase split into packages helps either way: our shared HTML package has no Vapor dependency at all, so it will not move.
Scaling Swift Projects With Modular Packages
Do the async half now
Vapor 5 is a beta with open milestones and no date, and a production app should not be running it. But waiting for the guide is not the same as waiting to start. Put your toolchain on a path to Swift 6.4. Move the entrypoint, handlers, middleware, migrations and lifecycle hooks to their async forms in Vapor 4. Write down every .get() and every req.db. Then leave Fluent, WebSockets and the macros alone until the Vapor team has said what they look like.
When the migration guide arrives, you want it to read like a list of renames. Most of what decides that is already in your hands.
Comments
Loading comments…