Skip to the content
OrielDocs
0.1.0-alpha.10GitHub

The basics

Routing

Routes map a URL and an HTTP method to a closure or a controller action. Once route:types has run, TypeScript checks every link to a named route.

Basic routing#

A basic route takes a URI and a closure:

routes/web.ts
import { Route } from '@orieljs/framework'Route.get('/greeting', () => 'Hello World')

A string, a number or a boolean is sent as HTML, null and undefined as an empty 204 response, and an object, an array or a model as JSON. Return a view, a redirect or a Response for anything else: Responses has the list.

The default route files#

Your routes live in the routes folder, and bootstrap/app.ts names the files to load. routes/web.ts is for your pages: its routes are in the web middleware group, which gives them cookies, the session and CSRF protection. The kits with pages split their routes over three files, loaded in this order:

Application.configure({ basePath: '.' }).withRouting({  web: ['routes/web.ts', 'routes/settings.ts', 'routes/auth.ts'],  console: 'routes/console.ts',  health: '/up',})

A route file usually calls the Route facade at its top level. It may also export a default function, which is called with the router.

API routes#

If your application also offers a stateless API, install:api adds one:

Terminal
node oriel install:api

It writes routes/api.ts and adds it to withRouting() in bootstrap/app.ts:

routes/api.ts
import { request, Route } from '@orieljs/framework'Route.get('/user', () => request().user()).middleware('auth:token')

The routes in routes/api.ts are in the api middleware group, which has no session, no cookie encryption and no CSRF check, and their URIs start with /api. auth:token signs the user in from a personal access token, whose table is in a new app's migrations (API tokens covers them). The API starter kit has its own routes/api.ts, with its token routes.

Available router methods#

There is a method for each HTTP verb:

Route.get('/posts', handler)Route.post('/posts', handler)Route.put('/posts/{id}', handler)Route.patch('/posts/{id}', handler)Route.delete('/posts/{id}', handler)Route.options('/posts', handler)

match() takes several verbs, and any() answers them all:

Route.match(['get', 'post'], '/search', handler)Route.any('/webhook', handler)

A GET route answers HEAD too, with the same status and headers and no body. A request whose method the URI doesn't accept gets a 405 with an Allow header listing the methods it does, and an OPTIONS request to a URI with no OPTIONS route gets a 200 with that header.

Dependency injection#

A route's closure receives its route parameters and nothing else. The current request comes from the request() helper, which works anywhere in your code while a request is handled:

Route.get('/users', () => {  const page = request().integer('page', 1)  return User.query().forPage(page).get()})

CSRF protection#

An HTML form that posts to a route in the web group must send the CSRF token, or the request is refused with a 419. csrfField() writes the hidden field:

resources/views/profile.tsx
import { csrfField } from '@orieljs/framework'export default function Profile() {  return (    <form method="post" action="/profile">      {csrfField()}      <button type="submit">Save</button>    </form>  )}

CSRF protection explains the token and the other ways to send it.

Redirect routes#

A route that only redirects needs no closure. Route.redirect() answers every verb with a 302, or the status you give it, and Route.permanentRedirect() with a 301:

Route.redirect('/here', '/there')Route.redirect('/here', '/there', 301)Route.permanentRedirect('/old-blog', '/blog')

The target may use the route's parameters: Route.redirect('/posts/{id}', '/articles/{id}').

View routes#

Route.view() shows a view, with the data you give it and the route's parameters:

Route.view('/welcome', 'welcome')Route.view('/welcome', 'welcome', { name: 'Taylor' })

Route.inertia() does the same for an Inertia page, with its props:

Route.inertia('/settings/appearance', 'Settings/Appearance')

Resource routes#

Route.resource() registers the seven routes of a resource controller in one line, and Route.apiResource() the five an API needs, without the pages for its forms:

Route.resource('photos', PhotoController)Route.apiResource('photos', PhotoController)

Controllers lists their URIs, names and options.

Listing your routes#

route:list shows every route with its methods, URI, name and action, sorted by URI:

Terminal
node oriel route:list

Filter it by method, by part of the name or by part of the URI, or leave out the URIs that contain a text:

Terminal
node oriel route:list --method=postnode oriel route:list --name=postsnode oriel route:list --path=admin --except-path=admin/reports

--reverse reverses the order, and --json prints the routes as JSON, with each route's middleware.

Routing customization#

withRouting() in bootstrap/app.ts loads your routes once every service provider has booted, so a pattern or a binding you set in a provider's boot() is in place when your route files run:

bootstrap/app.ts
import { dirname } from 'node:path'import { Application } from '@orieljs/framework'import providers from './providers'export function createApplication(): Promise<Application> {  return Application.configure({ basePath: dirname(import.meta.dirname) })    .withRouting({ web: 'routes/web.ts', console: 'routes/console.ts', health: '/up' })    .withProviders(providers)    .create()}

web and api take one file or a list, console names the file of your closure commands, and health the URI of the health check page, which answers 200 when your app is up. apiPrefix changes the API routes' prefix from api:

Application.configure({ basePath: '.' }).withRouting({ api: 'routes/api.ts', apiPrefix: 'api/admin' })

Route parameters#

Required parameters#

Capture a segment of the URI with braces:

Route.get('/user/{id}', (id: string) => `User ${id}`)

A route may have as many parameters as it needs. The action receives them in the order they appear in the URI, so the names in your function don't have to match:

Route.get('/teams/{team}/members/{member}', (teamId: string, memberId: string) => {  return { teamId, memberId }})

A parameter's name is letters, digits and underscores. A parameter named after one of your models receives the model rather than the text (see Route model binding), so {team} stays a string only while your app has no Team model.

Parameters and dependency injection#

Nothing but the route's parameters is passed to the action, whatever else it needs. Read the rest from the request:

Route.get('/user/{id}', (id: string) => {  const tab = request().query('tab', 'profile')  return `User ${id}, ${tab}`})

Optional parameters#

Add a ? to make a parameter optional. When the segment is missing, the parameter is undefined, so give it a default:

Route.get('/user/{name?}', (name?: string) => name ?? 'Guest')Route.get('/user/{name?}', (name = 'John') => name)

Regular expression constraints#

where() constrains a parameter with a regular expression. A URI whose segment doesn't match gets a 404:

Route.get('/user/{name}', (name: string) => name).where('name', '[A-Za-z]+')Route.get('/user/{id}', (id: string) => id).where('id', '[0-9]+')Route.get('/user/{id}/{name}', (id: string, name: string) => ({ id, name })).where({ id: '[0-9]+', name: '[a-z]+' })

The common patterns have their own methods, which take one parameter or a list:

Route.get('/user/{id}/{name}', (id: string, name: string) => ({ id, name }))  .whereNumber('id')  .whereAlpha('name')Route.get('/user/{name}', (name: string) => name).whereAlphaNumeric('name')Route.get('/user/{id}', (id: string) => id).whereUuid('id')Route.get('/user/{id}', (id: string) => id).whereUlid('id')Route.get('/category/{category}', (category: string) => category).whereIn('category', ['movie', 'song', 'painting'])

whereIn() matches each value as written, so a value such as c++ isn't read as a pattern.

Global constraints#

A pattern that every route should share goes in a service provider's boot(), with Route.pattern():

app/Providers/AppServiceProvider.ts
import { Route, ServiceProvider } from '@orieljs/framework'export class AppServiceProvider extends ServiceProvider {  override boot(): void {    Route.pattern('id', '[0-9]+')  }}

From then on, every {id} must be a number. A route's own where() wins over the global pattern.

Encoded forward slashes#

A parameter matches anything but a /. To let it match the rest of the path, slashes included, constrain it with .*:

Route.get('/search/{search}', (search: string) => search).where('search', '.*')

Parameters arrive decoded: /search/a%2Fb gives a/b.

Named routes#

A name lets you build a route's URL, or redirect to it, without writing the URI again:

Route.get('/user/profile', () => view('profile')).name('profile')

Controller actions take names the same way:

Route.get('/posts/{post}', [PostController, 'show']).name('posts.show')

Generating URLs to named routes#

route() builds the URL of a named route, and redirect().route() a redirect to it:

const url = route('profile')const response = redirect().route('profile')

A route's parameters go in an object. Any key the route doesn't use is added to the query string:

Route.get('/user/{id}/profile', (id: string) => id).name('profile')route('profile', { id: 1, photos: 'yes' })// http://localhost:8000/user/1/profile?photos=yes

You can also pass a single value for a route's first parameter, or a list of values in the URI's order. A model fills a parameter with its route key, or with the column a {post:slug} parameter names:

route('posts.show', post)route('posts.show', [post])route('posts.show', { post, page: 2 })

route() returns an absolute URL on the current request's host, or on app.url outside a request. Pass false as its third argument for a path:

route('posts.show', post, false) // /posts/1

An optional parameter you leave out is dropped from the URL. A required one is an error: "Missing required parameter for [Route: posts.show] [URI: posts/{post}] [Missing parameter: post].".

Typed route parameters#

route:types reads your routes and writes their names and parameters for TypeScript:

Terminal
node oriel route:types

From then on, route() and redirect().route() accept only names that exist, and want the parameters each route defines: a renamed route or a missing parameter fails the type check everywhere it is linked to.

route('posts.show', post)route('posts.show', { post: post.id, page: 2 })// @ts-expect-error: no route is named post.showroute('post.show', post)// @ts-expect-error: the route needs its postroute('posts.show')// @ts-expect-error: getKey() is unknown; pass the model or post.idroute('posts.show', { post: post.getKey() })

A parameter takes a string, a number, a boolean or a model. A model's getKey() is typed unknown, so pass the model itself or its typed id.

route:types writes two files, both ignored by git: types/routes.d.ts for your server code, and resources/js/oriel/routes.ts for your pages. --server and --output write them somewhere else. The Inertia and API starter kits run it in npm run typecheck. The Inertia kits' Vite plugin also runs it when Vite starts, and again whenever a file in routes/ or app/Http/Controllers/ changes while npm run dev runs. In the Live kit, run it yourself. Until the first run, any name is accepted.

Route URLs in your pages#

Your Inertia pages build URLs with the same route(), from your adapter. It takes the same names and parameters, and returns a path. The browser's route table leaves out the routes that types.except_routes in config/inertia.ts names, with * as a wildcard. Unless you set it, it leaves out live.*, Live's own routes.

resources/js/components/PostLink.tsx

resources/js/components/PostLink.tsx

import { Link, route } from '@orieljs/inertia-react'export function PostLink({ id, title }: { id: number; title: string }) {  return <Link href={route('posts.show', { post: id })}>{title}</Link>}

Inspecting the current route#

request().routeIs() tells you whether the current request matched a route of that name. It takes several names, and * as a wildcard:

if (request().routeIs('admin.*')) {  // ...}

Route groups#

A group shares middleware, a controller, a URI prefix or a name prefix between its routes. Groups can be nested: a nested group adds its middleware after its parent's, and its prefixes after its parent's prefixes.

Middleware#

middleware() before group() gives every route in the group its middleware, which run in the order you list them, except that auth and guest run first (Sorting middleware has the rule):

Route.middleware(['auth', 'verified']).group(() => {  Route.get('/dashboard', () => view('dashboard')).name('dashboard')  Route.get('/settings', () => view('settings')).name('settings')})

For a single route, chain the route's own middleware(): Route.get('/profile', handler).middleware('auth').

Controllers#

When every route of a group calls the same controller, name it once with controller(). Each route then gives only the method's name:

routes/web.ts
import { Route } from '@orieljs/framework'import { PostController } from '../app/Http/Controllers/PostController'Route.controller(PostController).group(() => {  Route.get('/posts', 'index')  Route.get('/posts/{post}', 'show')  Route.post('/posts', 'store')})

Inside a controller group, the method's name is a plain string, which TypeScript doesn't check: a misspelt one is a 500 ("Method PostController::shwo does not exist.") when the route is called. Outside a group, the [PostController, 'index'] form is checked.

Route prefixes#

prefix() adds a segment in front of each URI in the group:

Route.prefix('admin').group(() => {  Route.get('/users', () => 'Users') // matches /admin/users})

Route name prefixes#

name() adds a prefix to each route's name. It is added as written, so end it with a .:

Route.name('admin.').group(() => {  Route.get('/users', () => 'Users').name('users') // named admin.users})

Route model binding#

A route often looks a model up by the ID in its URI. Route model binding does it for you: the action receives the model rather than the ID.

Implicit binding#

When a parameter is named after one of your models, its segment is that model's route key. {user} finds the User whose id matches the segment, or answers 404 before your code runs:

routes/web.ts
import { Route } from '@orieljs/framework'import type { User } from '../app/Models/User'Route.get('/users/{user}', (user: User) => user.email)

The name is matched to a model in app/Models by its StudlyCase form: {post} is Post, {blog_post} and {blogPost} are BlogPost. A parameter that names no model stays a string.

Binding works the same for controller actions:

app/Http/Controllers/PostController.ts
import { view } from '@orieljs/framework'import type { Post } from '../../Models/Post'import { Controller } from './Controller'export class PostController extends Controller {  show(post: Post) {    return view('posts.show', { post })  }}

Soft deleted models#

A soft deleted model isn't found, so its route answers 404. There is no withTrashed() on routes.

Customizing the key#

Name a column after a colon to look the model up by that column:

Route.get('/posts/{post:slug}', (post: Post) => view('posts.show', { post }))

To use a column for every route of a model, override getRouteKeyName() on the model. route() then fills the parameter from that column too:

app/Models/Post.ts
import { Model } from '@orieljs/framework'export class Post extends Model {  override getRouteKeyName(): string {    return 'slug'  }}

Explicit binding#

A parameter that isn't named after its model is bound with Route.model(), in a service provider's boot():

app/Providers/AppServiceProvider.ts
import { Route, ServiceProvider } from '@orieljs/framework'import { User } from '../Models/User'export class AppServiceProvider extends ServiceProvider {  override boot(): void {    Route.model('author', User)  }}

Every {author} parameter is now a User, found by its route key, and a missing one is a 404:

Route.get('/posts/by/{author}', (author: User) => author.posts().get())

Customizing the resolution logic#

Route.bind() resolves a parameter however you like. The callback receives the segment, and the action receives what it returns:

app/Providers/AppServiceProvider.ts
import { Route, ServiceProvider } from '@orieljs/framework'import { User } from '../Models/User'export class AppServiceProvider extends ServiceProvider {  override boot(): void {    Route.bind('member', (value) => User.where('name', value).firstOrFail())  }}

A Route.bind() callback comes first, then Route.model(), then binding by name. To change how a model is found on every route, override the model's resolveRouteBinding(value, field), which returns the model or undefined, or its resolveRouteBindingQuery(query, value, field), which returns the query that finds it. Inside that override, TypeScript can't tell which columns the model has, so every column name is a type error there: a condition such as whereNotNull('published_at') runs, but doesn't type-check.

Fallback routes#

Route.fallback() answers any GET request that no other route matched, in place of the 404 page. It is always tried last, wherever you define it, and when it sits in routes/web.ts, the web middleware run for it:

Route.fallback(() => view('errors.not-found'))

Inside a group with a prefix, a fallback answers only the URIs under that prefix. A POST to an unknown URI is still a 404.

Rate limiting#

Attaching rate limiters to routes#

The throttle middleware limits how often a client may call a route. Its parameters are the number of requests and the window in minutes:

Route.middleware('throttle:60,1').group(() => {  Route.post('/audio', handler)  Route.post('/video', handler)})

It counts per signed-in user, on any route, and per IP address for a guest. The counts are kept in your cache's default store, or in the store that a limiter key in config/cache.ts names. Each response carries X-RateLimit-Limit and X-RateLimit-Remaining headers. Past the limit, the route answers 429 ("Too Many Attempts.") with Retry-After, the seconds to wait, and X-RateLimit-Reset, the Unix time the window ends.

Every route behind throttle adds to the same count for a client, whatever its numbers. To count a group of routes on its own, give throttle a third parameter, a prefix for its key: throttle:10,1,uploads.

Form method spoofing#

HTML forms can only send GET and POST. To reach a PUT, PATCH or DELETE route from a form, post it with a hidden _method field, which methodField() writes:

resources/views/posts/edit.tsx
import { csrfField, methodField } from '@orieljs/framework'import type { Post } from '../../../app/Models/Post'export default function Edit({ post }: { post: Post }) {  return (    <form method="post" action={`/posts/${post.id}`}>      {methodField('PUT')}      {csrfField()}      <button type="submit">Update</button>    </form>  )}

The X-HTTP-Method-Override header does the same for a script. Only a POST can be spoofed, and never as GET or HEAD.

Accessing the current route#

The Route facade tells you which route is handling the current request, and the request gives you its parameters:

const current = Route.current() // the route, or undefined outside a requestconst name = Route.currentRouteName() // 'posts.show'const post = request().route('post') // the bound Postconst parameters = request().route() // every parameter, by name

Route.has('login') tells you whether a route of that name exists, as a new app's welcome page does to show its login link.

Privacy policy ·

esc