# AdonisJS v7 — Full Documentation > Complete documentation content for LLM consumption. > Source: https://github.com/adonisjs/v7-docs > Generated: 2026-05-10 --- # Introduction AdonisJS is a **backend-first, type-safe framework** for building web applications with Node.js and TypeScript. It provides the core building blocks for writing and maintaining complete backends, eliminating the need for third-party services to handle **common features such as authentication, file uploads, caching, and rate limiting**. Each AdonisJS application is written in TypeScript, runs in ESM mode, and offers end-to-end type safety across the entire stack. ## Who is AdonisJS for? AdonisJS is designed for developers building production web applications who need more structure than Express but less ceremony than enterprise frameworks. If you're building REST APIs, server-rendered applications, or full-stack web apps, and you value conventions over configuration, AdonisJS will feel natural. If you're coming from Laravel, Rails, or Django, you'll recognize the MVC patterns and conventions. If you've worked with Express or Fastify, you'll appreciate having structure and batteries included without sacrificing simplicity. ## Why AdonisJS AdonisJS provides the structure, consistency, and tooling expected from a full-featured framework, while remaining lightweight and modern in design. It is suitable for teams that value: - **Ownership of backend logic**: Build critical features in-house rather than depending on external services for authentication, rate-limiting, or background jobs. - **Cohesive developer experience**: Every AdonisJS application follows the same conventions and directory structure, making it easy to onboard new developers and share knowledge across teams. - **Unified ecosystem**: Core features are maintained together under consistent quality standards, eliminating dependency fragmentation. - **Extensibility and freedom**: Core features are built from low-level packages that you can use directly to create custom flows, integrations, or abstractions. AdonisJS is designed to provide everything you need for real-world backend applications, while remaining approachable and easy to configure. ## What can you build with AdonisJS? AdonisJS is designed for real-world backend applications: - **REST APIs**: Build type-safe APIs for mobile apps or SPAs. Companies use AdonisJS to power APIs serving millions of requests. - **Full-stack web applications**: Use Edge templates for server-rendered pages, or pair with Vue/React for hybrid applications. The MVC structure keeps your backend organized as your app grows. - **SaaS platforms**: Build multi-tenant applications without relying on third-party services for core functionality like authentication, authorization, or background jobs. Whether you're building a startup MVP or a production system serving thousands of users, AdonisJS provides the foundation without getting in your way. ## Practical, not overengineered Many frameworks introduce enterprise abstractions that complicate projects without adding clarity. AdonisJS focuses on a different approach. It offers a **practical development model** that focuses on clarity, type safety, and maintainability rather than patterns for their own sake. The framework encourages good structure through conventions but never enforces heavy architectural layers. The framework includes batteries such as routing, middleware, validation, and ORM out of the box, allowing teams to focus on application logic instead of building common patterns repeatedly. AdonisJS APIs are functional and modern. You can use class-based components where a structured approach is helpful, such as in controllers, models, and services. ## How AdonisJS compares **vs. Express/Fastify**: AdonisJS provides structure and conventions that Express lacks, while remaining just as performant. Instead of assembling packages yourself, you get an integrated toolkit out of the box. **vs. NestJS**: AdonisJS focuses on practical patterns over enterprise abstractions. No decorators everywhere, no dependency injection containers to configure, just straightforward TypeScript code that follows clear conventions. **vs. Laravel/Rails**: If you love Laravel or Rails but work in Node.js, AdonisJS brings that same cohesive experience: migrations, seeders, factories, model relationships, and consistent conventions. Choose AdonisJS when you want the productivity of a full-featured framework without the complexity of enterprise patterns or the fragmentation of minimal frameworks. ## MVC with a configurable view layer AdonisJS uses the **Model-View-Controller (MVC)** pattern to keep data, logic, and presentation separate. The view layer is optional and can be configured to fit your needs. You can use: - **Edge**, the official server-side templating engine, for traditional full-stack applications. - **Vue**, **React**, or another frontend framework to build a single-page or hybrid application. - Or skip the view layer entirely when building an API-first or backend-only service. Most backend code, such as routing, controllers, models, and middleware, stays the same no matter how you render views. This flexibility allows you to start with server-side rendering and transition to a modern SPA setup later, without modifying your core backend logic. ## Ecosystem and stability AdonisJS has been in active development since 2015, with version 7 representing years of real-world usage and refinement. The framework is maintained by its creator full-time, with support from the core team members and an active community. The ecosystem includes [official packages](https://adonisjs.com/packages) for common backend needs, all maintained by the core team with the same quality standards. Community packages extend functionality for specific needs like payment processing, cloud storage, and third-party integrations. All documentation, tooling, and packages follow semantic versioning, ensuring stable upgrades and long-term maintainability. ## Next steps AdonisJS documentation is organized to guide both new and experienced developers: - If this is your **first time** using AdonisJS, then continue reading all the docs in the **Start** section and eventually build an app by following the [Tutorial](./tutorial/hypermedia/overview.md). - If you already know the basics, explore the [Guides](../guides/basics/routing.md) to learn specific topics like validation, database management, or testing. --- # Pick your path This guide introduces AdonisJS's approach to frontend development and the three primary stacks you can choose from. You will learn: - Why AdonisJS is backend-first but frontend-flexible. - Understand the difference between Hypermedia, Inertia, and API-only approaches. - See how the View layer works in each stack. - Learn about the starter kits that provide opinionated setups for each approach. ## Overview AdonisJS is deeply opinionated about the backend, providing built-in authentication, authorization, validation, database tooling, and more, **but deliberately flexible about the frontend**. This backend-first philosophy means you get a robust foundation for building your server-side logic while choosing how you build your user interface. You can create traditional server-rendered applications, modern single-page applications, or anything in between, all using the same backend framework. A marketing website has different requirements than an admin dashboard, which differs from a mobile app's API backend. Rather than forcing you into a single approach, AdonisJS lets you choose the frontend stack that fits your needs. ## The three approaches AdonisJS supports three primary approaches to building your frontend. Each approach represents a different way of thinking about the View layer in your application's architecture. ### Hypermedia Hypermedia applications generate complete HTML pages on the server and send them to the browser. You build your interface using a template engine (AdonisJS provides [Edge](https://edgejs.dev)) and add interactivity using lightweight JavaScript libraries like Alpine.js or HTMX/Unpoly when needed. :::note{title="What is Hypermedia"} The term "Hypermedia" refers to HTML as a medium for building interactive applications, where the server drives the application state and the client (browser) displays it. If you're new to this concept, the HTMX project has an excellent essay explaining [Hypermedia-driven applications](https://htmx.org/essays/hypermedia-driven-applications/) in depth. ::: In a Hypermedia application: - The server is responsible for rendering your views. - Your controllers return HTML instead of JSON. - Navigation between pages happens through traditional page loads or progressively enhanced requests. This approach embraces the web's native capabilities and keeps most of your application's logic on the server where you have full control. Choose this approach when you want to build applications with the server in control, or you want to minimize the amount of JavaScript your users download. Hypermedia applications can be highly interactive using libraries like Alpine.js and HTMX while keeping your frontend codebase lean and your deployment simple. ### Inertia (React or Vue) Inertia.js provides a middle ground between server-rendered templates and SPAs. You use React or Vue components as your views while keeping server-side routing and controllers. AdonisJS officially supports building applications with React or Vue through Inertia, giving you the component-based development experience of modern frontend frameworks without the complexity of maintaining a separate single-page application. With Inertia: - Your backend routes map directly to frontend components, eliminating the complexity of dual routing systems. - Your controllers return data to Inertia components instead of rendering templates or returning JSON. - Navigation feels like a single-page application with smooth transitions, but your routing logic stays on the server where it's easier to protect and maintain. Inertia also simplifies form submissions and data fetching while keeping your application a monolithic deployment. You get a modern, reactive user experience without building and maintaining a separate API layer. Choose this approach when you want to use React or Vue but prefer server-side routing, you want to avoid the complexity of separate frontend and backend deployments, or you want a tightly integrated full-stack development experience. Visit [inertiajs.com](https://inertiajs.com) to learn more about how Inertia bridges the gap between server-side and client-side frameworks. ### API-only You can build a JSON API backend with AdonisJS while your frontend lives in a completely separate codebase. This approach creates a clear separation where AdonisJS handles all backend logic and exposes data through API endpoints, while your frontend application (built with any framework) consumes these endpoints. In an API-only setup: - Your controllers return JSON responses. - Your frontend and backend are separate deployments with their own build processes, repositories (or monorepo), and deployment pipelines. - The two communicate exclusively through HTTP requests to your API endpoints. :::note In monorepos, you can use a [type-safe API client](../guides/frontend/api_client.md) for true end-to-end typing across backend and frontend. [Transformers](../guides/frontend/transformers.md) also produce reusable, independent response types, so your UI can rely directly on the serialized API contract. ::: This approach covers a wide variety of applications: APIs for mobile apps (iOS, Android), web applications built with any frontend framework, desktop applications, or even multiple frontends (web and mobile) consuming the same API. The separation provides flexibility in how you deploy and scale each layer independently. Choose this approach when you're building an API that serves multiple client applications, your team prefers working with separate frontend and backend repositories, you need independent deployment and scaling of frontend and backend, or you're building a public API that external developers will consume. ## The same controller, three different returns In the following example, you can see us using the same route, same controller, same data fetching logic. Only the return statement changes. ::::tabs :::tab{title="Hypermedia"} ```ts title="start/routes.ts" import router from '@adonisjs/core/services/router' import { controllers } from '#generated/controllers' router.get('posts', [controllers.Posts, 'index']) ``` ```ts title="app/controllers/posts_controller.ts" import Post from '#models/post' import { HttpContext } from '@adonisjs/core/http' export default class PostsController { async index({ view }: HttpContext) { const posts = await Post.all() return view.render('posts/index', { posts }) // [!code highlight] } } ``` ```edge title="views/pages/posts/index.edge" @each(post in posts)

{{ post.title }}

@end ``` ::: :::tab{title="Inertia"} ```ts title="start/routes.ts" import router from '@adonisjs/core/services/router' import { controllers } from '#generated/controllers' router.get('posts', [controllers.Posts, 'index']) ``` ```ts title="app/controllers/posts_controller.ts" import Post from '#models/post' import { HttpContext } from '@adonisjs/core/http' // [!code highlight] import PostTransformer from '#transformers/post_transformer' export default class PostsController { async index({ inertia }: HttpContext) { const posts = await Post.all() // [!code highlight:3] return inertia.render('posts/index', { posts: PostTransformer.transform(posts) }) } } ``` ```tsx title="pages/posts/index.tsx" import { InertiaProps } from '~/types' import { Data } from '~/generated/data' type PageProps = InertiaProps<{ posts: Data.Post[] }> export default function PostsIndex({ posts }: PageProps) { return <> { posts.map((post) => (

{ post.title }

)) } } ``` ::: :::tab{title="API-only"} ```ts title="start/routes.ts" import router from '@adonisjs/core/services/router' import { controllers } from '#generated/controllers' router.get('posts', [controllers.Posts, 'index']) ``` ```ts title="app/controllers/posts_controller.ts" import Post from '#models/post' import { HttpContext } from '@adonisjs/core/http' // [!code highlight] import PostTransformer from '#transformers/post_transformer' export default class PostsController { async index({ serialize }: HttpContext) { const posts = await Post.all() // [!code highlight] return serialize(PostTransformer.transform(posts)) } } ``` ```json title="JSON response" { "data": [ { "id": 1, "title": "AdonisJS In 30", "description": "In this series, we'll ...", } ] } ``` ::: :::: ## What NOT to expect If you're coming from meta-frameworks, there are some patterns you won't find in AdonisJS. These differences are intentional and provide clarity about how your application works. :::note AdonisJS is designed to be the backend that powers your frontend applications, not a replacement for meta-frameworks. You can use AdonisJS alongside frameworks like TanStack Start, Nuxt, Next.js, or others, with clear boundaries and well-defined API contracts between the two. ::: **No file-based routing**: AdonisJS uses explicit route definitions in your route files. Routes are declared using the router API, giving you full control and visibility over your application's URL structure. This makes it easy to see all your routes in one place and apply middleware or constraints as needed. **No compiler magic with "use" directives**: You won't find magical statements like "use server" or "use client" that blur the boundaries between where code executes. AdonisJS maintains a clear separation between your backend code (which runs on the server) and your frontend code (which runs in the browser). **No mixing of server and client code**: Your backend logic lives in controllers, models, and services. Your frontend code lives in templates, components, or a separate frontend application. There's no ambiguity about where code runs, which makes debugging straightforward and helps teams work with well-defined boundaries. This separation provides clarity about where code executes, makes debugging predictable, and allows teams to work with clear contracts between frontend and backend. When your frontend needs data from your backend, you work with explicit API calls or template data passing, not magical boundaries that blur at compile time. ## Starter kits AdonisJS provides starter kits for each approach that come with opinionated configurations and best practices already in place. You don't have to configure everything from scratch. These starter kits include properly **configured build tools**, **authentication scaffolding**, and all the **necessary integrations set up correctly**. When you create a new AdonisJS application, you can choose which starter kit to use based on the approach you've decided on. This gives you a **"flexible but not on your own"** experience. You get to choose your stack, but once you've chosen, you get a fully configured setup that works out of the box. - [Hypermedia starter kit](https://github.com/adonisjs/starter-kits/tree/main/hypermedia) - [React starter kit](https://github.com/adonisjs/starter-kits/tree/main/inertia-react) - [Vue starter kit](https://github.com/adonisjs/starter-kits/tree/main/inertia-vue) - [API-only](https://github.com/adonisjs/starter-kits/tree/main/api) - [API monorepo](https://github.com/adonisjs/starter-kits/tree/main/api-monorepo) ## Next steps Now that you understand all different approaches AdonisJS supports, you're ready to create your first application. The [installation guide](./installation.md) will walk you through using the starter kits to set up a new project with your chosen stack. --- # Installation This guide explains how to set up a new AdonisJS application from scratch. It covers the prerequisites, project creation, and starting the development server. You do not need prior experience with AdonisJS, but basic knowledge of Node.js is useful. ## Prerequisites Before you begin, make sure you have the following tools installed. - **Node.js ≥ 24.x** - **npm ≥ 11.x** You can verify your installed versions using the following commands. ```sh node -v npm -v ``` Check that your Node.js and npm versions meet these requirements before continuing. You can download the latest versions from the [Node.js website](https://nodejs.org/) or use a version manager like Volta/nvm. ## Creating a new application AdonisJS provides the `create-adonisjs` initializer package to scaffold new applications. This package creates a new project directory with all necessary files, dependencies, and configuration based on your selections during the setup process. Replace `[project-name]` with your desired project name. The initializer will create a new directory with that name and set up your AdonisJS application inside it. ```sh npm create adonisjs@latest [project-name] ``` This command starts an interactive setup and asks you to select a starter kit, or you may pre-define a starter kit using the `--kit` CLI option. ```sh title="Create a new Hypermedia application" npm create adonisjs@latest [project-name] -- --kit=hypermedia ``` ```sh title="Create a new React application" npm create adonisjs@latest [project-name] -- --kit=react ``` ```sh title="Create a new Vue application" npm create adonisjs@latest [project-name] -- --kit=vue ``` ```sh title="Create a new API application" npm create adonisjs@latest [project-name] -- --kit=api ``` ### Official starter kits AdonisJS offers four official starter kits. Each kit sets up a different type of application, depending on how you want to build your user interface and manage interactivity. - [**Hypermedia Starter Kit**](https://github.com/adonisjs/starter-kits/tree/main/hypermedia). Uses Edge as the server-side templating engine and integrates Alpine.js to add lightweight, reactive behavior to your frontend. Ideal for applications that primarily render HTML on the server and only need minimal frontend logic. - [**React Starter Kit**](https://github.com/adonisjs/starter-kits/tree/main/inertia-react). Uses Inertia.js alongside React to build a fullstack React application powered by the AdonisJS backend. It can operate as a server-rendered app or a Single Page Application (SPA), depending on your configuration. - [**Vue Starter Kit**](https://github.com/adonisjs/starter-kits/tree/main/inertia-vue). Similar to the React setup, but with Vue as the frontend framework. It utilizes Inertia.js and provides the same full-stack capabilities, including backend-driven routing, shared state, and SPA support. - [**API Starter Kit**](https://github.com/adonisjs/starter-kits/tree/main/api). A monorepo setup with two apps: an AdonisJS backend and an empty frontend project where you can configure any frontend framework of your choice (TanStack Start, Nuxt, Next.js, or others). End-to-end type-safety and shared transformer types are already configured between the backend and frontend. All starter kits come pre-configured with sensible defaults, streamlined development workflows, and ready-to-use authentication features. For a detailed comparison and usage guidance, see the [Pick your path](./pick_your_path.md) guide. ### Community starter kits In addition to the official kits, the AdonisJS community also maintains starter kits for specific use cases. - [**Slim Starter Kit**](https://github.com/batosai/adonisjs-slim-starter-kit). A minimal AdonisJS v7 setup with the framework core and Japa test runner, designed for teams that want to start from a lightweight foundation. - [**MCP Starter Kit**](https://github.com/batosai/adonisjs-mcp-starter-kit). A minimal AdonisJS v7 setup with built-in MCP support, useful when you want to expose MCP tools, resources, and prompts in your application. ## Project defaults Every newly created AdonisJS application includes: - Opinionated folder structure. - [Lucid ORM](https://lucid.adonisjs.com) configured with **SQLite** as the default database. - Built-in **authentication** flows for login and signup. - **ESLint** and **Prettier** setup with pre-defined configuration. These features help you get started quickly. You can customize, extend, or remove them as your project grows. ## Starting the development server After creating your app, move into your project directory and start the development server. ::::tabs :::tab{title="Hypermedia / Inertia kits"} ```bash node ace serve --hmr ``` Once the server is running, open your browser and visit [http://localhost:3333](http://localhost:3333). You should see the AdonisJS welcome page confirming your installation was successful. ::: :::tab{title="API kit"} The API starter kit is a monorepo managed by [Turborepo](https://turbo.build/repo). Start all apps from the project root: ```bash npm run dev ``` This starts both the backend (AdonisJS) and frontend dev servers. The backend runs at [http://localhost:3333](http://localhost:3333) and returns a JSON response: ```json { "hello": "world" } ``` ::: :::: ## What you just installed Your starter kit includes: - **Pre-configured development environment**. TypeScript, ESLint, Prettier, and Vite are set up with sensible defaults. - **Database setup**. Lucid ORM is configured with SQLite, ready for you to start building models and running migrations. - **Organized project structure**. Routes are defined in `start/routes.ts`, models live in `app/models/`, controllers are in `app/controllers/`, and middleware resides in `app/middleware/`. This convention keeps your codebase organized as it grows. - **Working authentication**. All starter kits include a fully functional authentication system with signup and login flows. ::::tabs :::tab{title="Hypermedia / Inertia kits"} Try creating an account at [http://localhost:3333/signup](http://localhost:3333/signup) and logging in at [http://localhost:3333/login](http://localhost:3333/login). The `users` table already exists in your SQLite database (`tmp/db.sqlite`). ::: :::tab{title="API kit"} The authentication endpoints are available at `POST /api/v1/auth/signup` and `POST /api/v1/auth/login`. You can test them with any HTTP client (curl, Postman, or your frontend app). The `users` table already exists in your SQLite database (`apps/backend/tmp/db.sqlite`). ::: :::: ### Dev-server modes - **Hot Module Replacement (--hmr)**. This is the recommended approach for most development scenarios. HMR updates your application in the browser without requiring a full page reload, preserving your application's state while reflecting code changes instantly. This provides the fastest development feedback loop, especially when working on frontend components or styles. - **File watching (--watch)**. This mode automatically restarts the entire server process when you make changes to your code. While this approach takes slightly longer than HMR since it requires a full restart, it ensures a clean application state with every change and can be useful when working on server-side logic or when HMR updates aren't sufficient. ## Exploring other commands The `ace` command-line tool includes many commands for development and production workflows. To see all available commands, run the following. ```bash node ace ``` ## Next steps - **New to AdonisJS?** Follow the [Tutorial](./tutorial/hypermedia/overview.md) to build a complete application and learn how everything works together. - **Want to understand the codebase first?** Read [Folder Structure](./folder_structure.md) to see how the project is organized. --- # Folder structure When you create a new AdonisJS application, it comes with a thoughtful default folder structure designed to keep projects tidy, predictable, and easy to refactor. This structure reflects conventions that work well for most projects, but AdonisJS does not lock you into them. You are free to reorganize files and directories to suit your team's workflow. Depending on the starter kit you select, some files or directories may differ. For example, the **Inertia** starter kit contains a top-level `inertia` folder, whereas the **Hypermedia** starter kit does not include this folder. ## Overview Here's what a freshly created AdonisJS project looks like. ::::tabs :::tab{title="Hypermedia / Inertia kits"} ```sh ├── app ├── bin ├── config ├── database ├── resources ├── start ├── tests ├── ace.js ├── adonisrc.ts ├── eslint.config.js ├── package-lock.json ├── package.json ├── tsconfig.json └── vite.config.ts ``` ::: :::tab{title="API kit"} The API starter kit is a monorepo with two workspaces managed by [Turborepo](https://turbo.build/repo): ```sh ├── apps │ ├── backend # AdonisJS application │ │ ├── app │ │ ├── bin │ │ ├── config │ │ ├── database │ │ ├── start │ │ ├── tests │ │ ├── ace.js │ │ ├── adonisrc.ts │ │ ├── package.json │ │ └── tsconfig.json │ └── frontend # Your frontend application │ ├── package.json │ └── ... ├── package.json # Root package.json with workspaces ├── package-lock.json └── turbo.json # Turborepo pipeline configuration ``` The `apps/backend` directory contains a standard AdonisJS application — all the sections below (`app/`, `config/`, `start/`, etc.) apply to that directory. The `apps/frontend` directory is where you set up your frontend framework (TanStack Start, Next.js, Nuxt, or others). The root `package.json` defines the workspaces: ```json { "workspaces": ["apps/*"] } ``` And `turbo.json` configures the `dev`, `build`, and other scripts to run across both apps. When you run `npm run dev` from the project root, Turborepo starts both the backend and frontend dev servers in parallel. ::: :::: Each directory and file has a specific purpose. The sections below explain the role of each item and what you are likely to find there. ## `app/` The `app` directory organizes code for the domain logic of your application. For example, the controllers, models, mails, middleware, etc., all live within the `app` directory. Feel free to create additional folders in the `app` directory to better organize your codebase. ```sh ├── app │ ├── controllers │ ├── exceptions │ ├── mails │ ├── middleware │ ├── models │ ├── transformers │ └── validators ``` ## `bin/` The `bin` directory contains the entry point files used to start your AdonisJS application in different environments. - The `console.ts` file uses the Ace commandline framework to execute CLI commands. - The `server.ts` file starts the application in the web environment to listen for HTTP requests. - The `test.ts` file is used to boot the application for the testing environment. You usually won't need to modify these files unless you want to customize how the app boots in a specific context. ```sh ├── bin │ ├── console.ts │ ├── server.ts │ └── test.ts ``` ## `config/` All application and third-party configuration files live inside the `config` directory. You can also store config local to your application inside this directory. ```sh ├── config │ ├── app.ts │ ├── auth.ts │ ├── bodyparser.ts │ ├── database.ts │ ├── hash.ts │ ├── limiter.ts │ ├── logger.ts │ ├── mail.ts │ ├── session.ts │ ├── shield.ts │ ├── static.ts │ └── vite.ts ``` ## `database/` The `database` directory holds artifacts related to the database layer. By default, AdonisJS ships with Lucid ORM configured for SQLite; switching databases does not require reorganizing this folder. - `migrations/` - versioned schema changes - `seeders/` - scripts to insert initial or test data - `factories/` - blueprints for generating model instances in tests or seeders - `schema.ts` - auto-generated database schema used by models - `schema_rules.ts` - auto-generated schema rules used by validators See also: [Lucid documentation](https://lucid.adonisjs.com) ```sh database/ ├── migrations/ ├── seeders/ ├── factories/ ├── schema.ts └── schema_rules.ts ``` ## `providers/` The `providers` directory is used to store the [service providers](../guides/concepts/service_providers.md) used by your application. You can create new providers using the `node ace make:provider` command. ```sh ├── providers │ └── app_provider.ts ``` ## `public/` The `public` directory contains raw static assets that are served directly to the browser without any compilation step. Files in this directory are publicly accessible over HTTP using the `http://localhost:3333/public/` URL. :::note The API starter kit does not include a `public` directory, since the backend serves only JSON responses and does not serve static assets. ::: Typical examples of files stored in this folder include: - Favicon `(favicon.ico)` - Robots file `(robots.txt)` - Static images `(logo.png, social-banner.jpg)` - Downloadable assets `(manual.pdf)` ## `resources/` The `resources` directory stores Edge templates and uncompiled frontend assets such as CSS and JavaScript files. :::note The API starter kit does not include a `resources` directory, since the backend serves only JSON responses and does not render HTML templates. ::: For applications using Inertia (alongside Vue or React), the frontend code is kept within the `inertia` directory, and the `resources` directory contains only the root Edge template. Think of this root template as the `index.html` file that contains the HTML shell for your frontend application. ::::tabs :::tab{title="Hypermedia app"} ```sh ├── resources │ ├── css │ ├── js │ └── views │ ├── home.edge ``` ::: :::tab{title="Inertia app"} ```sh ├── resources │ └── views │ └── inertia_layout.edge ``` ::: :::: ## `inertia/` The `inertia` directory exists only in projects using the Inertia starter kit. It represents a sub-application containing the frontend source code, including React or Vue components, pages, and supporting utilities. - `pages/` - stores your Inertia pages written in React or Vue. - `app.(tsx|vue)` - the main entry point for the client-side application. - `ssr.(tsx|vue)` - the entry point for server-side rendering (SSR). - `tsconfig.json` - The TypeScript config file for the frontend codebase. The defaults are optimized for browser environments, JSX/TSX syntax, and Vite-based builds You are free to create additional subfolders, such as `components/`, `layouts/`, or `utils/`, to organize your frontend code. ```sh ├── inertia │ ├── css │ ├── layouts │ ├── pages │ │ └── home.tsx │ ├── app.tsx │ ├── ssr.tsx │ ├── tsconfig.json │ └── types.ts ``` ### Clear separation between frontend and backend AdonisJS maintains a clear boundary between the backend and the frontend. You should **never import backend code** (such as models, services, or transformers) into your frontend application. In practice, your frontend communicates with the backend through HTTP requests and **receives plain JSON data**. AdonisJS encourages you to model this reality explicitly. Data is fetched and transformed via API responses, rather than being hidden behind shared abstractions. ### Shared types The frontend can still rely on shared TypeScript types automatically generated by AdonisJS. These are stored inside the `.adonisjs/client` directory and include type definitions for routes, props, and other shared contracts. This approach allows frontend code to remain strongly typed without compromising the separation between the client and the server. :::tip You should commit the `.adonisjs` directory to version control. The framework relies on these generated files for import resolution (e.g. `#generated/controllers`) and TypeScript type checking. Without them, production builds and CI pipelines will fail. ::: We recommend reading the [types generation docs](../guides/frontend/transformers.md#step-4-understanding-the-generated-types) to understand how AdonisJS creates shared types. ## `start/` The `start` directory contains the files you want to import during the boot lifecycle of the application. For example, the files to register routes and define event listeners should live within this directory. ```sh ├── start │ ├── env.ts │ ├── kernel.ts │ └── routes.ts ``` AdonisJS does not auto-import files from the `start` directory. It is merely used as a convention to group similar files. We recommend reading about [preload files](../guides/concepts/application_lifecycle.md#running-code-before-the-application-starts) and the [application boot lifecycle](../guides/concepts/application_lifecycle.md) to have a better understanding of which files to keep under the start directory. ## `tests/` The `tests` directory contains automated tests. AdonisJS uses the [Japa](https://japa.dev) test runner. Tests are organized within specific sub-folders. When running `unit` tests, AdonisJS boots the application but does not start the HTTP server. Whereas, during `functional` tests, we start the HTTP server and the Vite Dev-server. See also: [Testing docs](../guides/testing/introduction.md) ```sh ├── tests │ ├── unit │ ├── functional │ └── bootstrap.ts ``` ## `tmp/` The temporary files generated by your application are stored within the `tmp` directory. For example, these could be user-uploaded files (generated during development) or logs written to the disk. The `tmp` directory must be ignored by the `.gitignore` rules, and you should not copy it to the production server either. ## `ace.js` The `ace.js` file is the entry point for executing Ace commands. This file configures a JIT TypeScript compiler and then imports the `bin/console.ts` file. ## `adonisrc.ts` `adonisrc.ts` is the project manifest. It tells AdonisJS how to discover and load parts of your application and includes configuration for: - Directory aliases (for `app`, `start`, etc.) - Preload files - Registered providers and commands - Assembler hooks See also: [AdonisRC file reference](../reference/adonisrc_file.md) ## `eslint.config.js` This file configures ESLint for the project. The default rules are tuned for TypeScript and AdonisJS conventions. You can modify or extend this configuration to match your team's style. ## `package.json` and `package-lock.json` - `package.json` - project metadata, scripts, and dependency declarations. - `package-lock.json` - locks exact dependency versions for consistent installs. ## `tsconfig.json` `tsconfig.json` controls TypeScript compiler options, module resolution, and path aliases. The provided configuration is suitable for both development and production builds. However, you can adjust the compiler settings to match your specific workflow. :::note AdonisJS relies on `experimentalDecorators` for Dependency Injection, Ace commands, and the Lucid ORM. ::: The following configuration options are required for AdonisJS internals to work correctly. ::::tabs :::tab{title="Non-Inertia apps"} ```json { "compilerOptions": { "module": "NodeNext", "isolatedModules": true, "declaration": false, "outDir": "./build", "esModuleInterop": true, "experimentalDecorators": true, "emitDecoratorMetadata": true, "skipLibCheck": true }, "include": ["**/*", ".adonisjs/server/**/*"] } ``` ::: :::tab{title="Inertia apps"} ```json { "compilerOptions": { "module": "NodeNext", "isolatedModules": true, "declaration": false, "outDir": "./build", "esModuleInterop": true, "experimentalDecorators": true, "emitDecoratorMetadata": true, "skipLibCheck": true }, // [!code ++:5] "references": [ { "path": "./tsconfig.inertia.json" } ], "include": ["**/*", ".adonisjs/server/**/*"] } ``` ::: :::: ## `vite.config.ts` When the project uses Vite (Edge or Inertia starter kits), `vite.config.ts` defines how frontend assets are compiled and served. Customize entry points, aliases, and plugin settings here if you need specialized bundling behavior. --- # Development environment setup This guide covers the recommended development environment for AdonisJS applications. You will learn how to: - Configure TypeScript with required compiler options - Set up ESLint and Prettier for code quality - Install recommended code editor extensions - Choose a database for local development ## Overview AdonisJS applications come with a fully configured development environment out of the box. **TypeScript, ESLint, and Prettier** are pre-configured with sensible defaults, allowing you to start building immediately without manual setup. This guide explains what's already configured in your project, recommends optional editor extensions that enhance the development experience, and provides guidance on choosing a database for local development. Understanding these configurations helps you leverage the full capabilities of the framework and maintain consistency across your team. ## Code editors and extensions AdonisJS works with any modern code editor that supports **TypeScript**. The framework does not rely on custom domain-specific languages (DSLs), so most editors provide full language support out of the box. The only framework-specific syntax is the **Edge templating engine**, which benefits from dedicated syntax highlighting extensions. :::card{name="AdonisJS Extension" logo="resources/assets/adonisjs-icon.svg"} --- links: - href: https://marketplace.visualstudio.com/items?itemName=jripouteau.adonis-vscode-extension text: VSCode --- Provides IntelliSense for AdonisJS APIs, file generators, and command palette integration for running Ace commands. ::: :::card{name="Japa Extension" logo="resources/assets/japa-icon.svg"} --- links: - href: https://marketplace.visualstudio.com/items?itemName=jripouteau.japa-vscode text: VSCode --- Test runner integration for running individual tests or test suites directly from the editor. ::: :::card{name="Edge Templates Extension" logo="resources/assets/edge-icon.svg"} --- links: - href: https://marketplace.visualstudio.com/items?itemName=AdonisJS.vscode-edge text: VSCode - href: https://zed.dev/extensions/edge text: Zed - href: https://packagecontrol.io/packages/Edge%20templates%20extension text: Sublime Text --- Full syntax highlighting and basic autocomplete for Edge template files. ::: ## TypeScript setup TypeScript is a first-class citizen in AdonisJS. Every application is created and runs using TypeScript by default, with all configuration handled automatically. Understanding how TypeScript works in development versus production, and the required compiler options, helps you make informed decisions about deployment and tooling. ### Required TypeScript configuration AdonisJS requires specific TypeScript compiler options to function correctly. The framework relies heavily on **experimental decorators** for dependency injection, model definitions, and Ace commands. The following `tsconfig.json` configuration represents the bare minimum required for AdonisJS applications. ::::tabs :::tab{title="Non-Inertia apps"} ```json { "compilerOptions": { "module": "NodeNext", "isolatedModules": true, "declaration": false, "outDir": "./build", "esModuleInterop": true, "experimentalDecorators": true, "emitDecoratorMetadata": true, "skipLibCheck": true }, "include": ["**/*", ".adonisjs/server/**/*"] } ``` ::: :::tab{title="Inertia apps"} ```json { "compilerOptions": { "module": "NodeNext", "isolatedModules": true, "declaration": false, "outDir": "./build", "esModuleInterop": true, "experimentalDecorators": true, "emitDecoratorMetadata": true, "skipLibCheck": true }, // [!code ++:5] "references": [ { "path": "./inertia/tsconfig.json" } ], "include": ["**/*", ".adonisjs/server/**/*"] } ``` ::: :::: ### Development mode (JIT compilation) In development, AdonisJS uses a **Just-in-Time (JIT) compiler** provided by the `@poppinss/ts-exec` package. This approach executes TypeScript files directly without a separate compilation step, enabling instant feedback when you save changes. This differs from Node.js' native TypeScript support because AdonisJS requires: - **Experimental decorators** support (used for dependency injection and model decorators) - **JSX compilation** (if you replace Edge with a JSX-based template engine like Inertia) Since Node.js' built-in TypeScript loader does not support these features, [`@poppinss/ts-exec`](https://github.com/poppinss/ts-exec) provides the necessary compatibility layer. ### Production mode (ahead-of-time compilation) For production deployments, AdonisJS compiles your TypeScript code into JavaScript using the official TypeScript compiler (`tsc`). This process generates a `build/` directory containing transpiled `.js` files optimized for the Node.js runtime. ```bash node ace build ``` The compiled output includes: - Transpiled JavaScript files with decorators transformed - Copied static assets and templates - The `package.json` and your package manager lock file. You must `cd` into the build directory, install dependencies and start the production server. See also: [Deploying to production](./deployment.md) ## ESLint and Prettier configuration AdonisJS projects include pre-configured **ESLint** and **Prettier** setups that enforce TypeScript best practices and maintain consistent code formatting across your team. :::tip Most code editors support running ESLint and Prettier automatically on file save. Configuring this in your editor eliminates manual formatting steps and catches linting issues immediately. ::: ### ESLint The default ESLint configuration extends the AdonisJS base config, which includes rules for TypeScript, async/await patterns, and framework conventions. You can override or extend these rules in `eslint.config.js` as needed. ```js title="eslint.config.js" import { configApp } from '@adonisjs/eslint-config' export default configApp() ``` Run ESLint manually: ```bash npm run lint ``` ### Prettier Prettier configuration is defined in `package.json`, ensuring all files are formatted consistently. The AdonisJS preset includes sensible defaults for indentation, quotes, and line length. ```json title="package.json" { "prettier": "@adonisjs/prettier-config" } ``` Run Prettier manually: ```bash npm run format ``` See also: [ESLint configuration reference](https://github.com/adonisjs/tooling-config/tree/main/packages/eslint-config), [Prettier configuration reference](https://github.com/adonisjs/tooling-config/tree/main/packages/prettier-config) ## Database setup AdonisJS applications are pre-configured with **SQLite**, a lightweight file-based database. SQLite requires no installation and stores data in a local `tmp/database.sqlite` file, allowing you to start building immediately without setting up external database servers. However, most applications use PostgreSQL or MySQL in production. We recommend [switching to your production database](../guides/database/lucid.md#configuration) engine early in development to avoid schema differences and driver-specific behavior that can cause deployment issues. ### Local database tools You can use the following tools to run PostgreSQL or MySQL locally: - [Dbngin](https://dbngin.com/) (macOS and Windows) for managing PostgreSQL and MySQL through a GUI - [Docker](https://www.docker.com/) for running databases in isolated containers - [Postgres.app](https://postgresapp.com/) for native PostgreSQL on macOS --- # Configuration & Environment This guide covers configuration in AdonisJS applications. You will learn about: - Config files in the `config` directory - Environment variables and the `.env` file - Validating environment variables with type safety - Variable interpolation within `.env` files - Environment-specific `.env` files for development, testing, and production - Accessing configuration in Edge templates - The `adonisrc.ts` file for framework configuration ## Overview Configuration in AdonisJS is organized into three distinct systems, each serving a specific purpose. - **Config files** contain your application settings. These files live in the `config` directory and define things like database connections, mail settings, and session configuration. - **Environment variables** stored in the `.env` file hold runtime secrets and values that change between environments. API keys, database passwords, and environment-specific URLs belong here. AdonisJS supports multiple `.env` files for different environments and provides type-safe validation to catch missing variables at startup. - **The adonisrc.ts file** configures the framework itself. It tells AdonisJS how your workspace is organized, which providers to load, and which commands are available. ## Configuration files Configuration files live in the `config` directory at the root of your project. Each file exports a configuration object for a specific part of your application (database connections, mail settings, authentication, session handling, and so on). A typical AdonisJS project includes several config files out of the box. The `config/database.ts` file configures database connections, `config/mail.ts` handles email delivery, and `config/auth.ts` defines authentication settings. Here's what a database configuration file looks like. ```ts title="config/database.ts" import app from '@adonisjs/core/services/app' import { defineConfig } from '@adonisjs/lucid' const dbConfig = defineConfig({ connection: 'sqlite', prettyPrintDebugQueries: true, connections: { sqlite: { client: 'better-sqlite3', connection: { filename: app.tmpPath('db.sqlite3'), }, useNullAsDefault: true, migrations: { naturalSort: true, paths: ['database/migrations'], }, debug: app.inDev, }, }, }) export default dbConfig ``` Mail configuration follows a similar pattern. ```ts title="config/mail.ts" import env from '#start/env' import { defineConfig, transports } from '@adonisjs/mail' const mailConfig = defineConfig({ default: env.get('MAIL_MAILER'), from: { address: env.get('MAIL_FROM_ADDRESS'), name: env.get('MAIL_FROM_NAME'), }, mailers: { resend: transports.resend({ key: env.get('RESEND_API_KEY'), baseUrl: 'https://api.resend.com', }), }, }) export default mailConfig declare module '@adonisjs/mail/types' { export interface MailersList extends InferMailers {} } ``` Notice how this config file references environment variables through `env.get()`. This is the correct way to use environment-specific values in your configuration. The config file defines the structure and defaults, while the `.env` file provides the actual values. ### When config files are loaded Configuration files are loaded during the application boot cycle, before your routes and controllers are ready. This means you should keep config files simple and avoid importing application-level code like models, services, or controllers. Config files should only import framework utilities, define configuration objects, and reference environment variables. **Importing application code creates circular dependencies and will cause your app to fail during startup**. ### Accessing config in Edge templates Edge templates have access to your application's configuration through the `config` global. This allows you to reference configuration values directly in your views without passing them explicitly from controllers. ```edge title="resources/views/layouts/main.edge" {{ config('app.appName') }}

Running in {{ config('app.nodeEnv') }} mode

``` The `config()` helper accepts a dot-notation path to any configuration value. The path corresponds to the config file name and the property within it. For example, `config('database.connection')` reads the `connection` property from `config/database.ts`. You can also provide a default value as the second argument. ```edge

{{ config('app.timezone', 'UTC') }}

``` ## Environment variables Environment variables store secrets and configuration that varies between environments. During development, you define these variables in the `.env` file. In production, you must define them through your hosting provider's UI or configuration interface. A typical `.env` file looks like this. ```bash title=".env" HOST=0.0.0.0 PORT=3333 APP_KEY=your-secret-app-key-here MAIL_MAILER=resend MAIL_FROM_ADDRESS=hello@example.com MAIL_FROM_NAME=My App RESEND_API_KEY=re_your_api_key_here ``` The `.env` file is already listed in `.gitignore` in AdonisJS starter kits, so you won't accidentally commit secrets to your repository. ### The APP_KEY The `APP_KEY` is a special environment variable that AdonisJS uses for encrypting cookies, signing sessions, and other cryptographic operations. Every AdonisJS application requires an APP_KEY to function securely. Run the `generate:key` command to create your APP_KEY. ```bash node ace generate:key ``` This creates a cryptographically secure random key and adds it to your `.env` file automatically. The APP_KEY must remain secret. Anyone with access to this key can decrypt your application's encrypted data and forge session tokens. When you deploy to production, use a different APP_KEY for each environment (development, staging, production). Never reuse keys across environments. If your APP_KEY is compromised, generate a new one immediately. This will invalidate all existing user sessions and encrypted data. ### Using environment variables in config files Config files access environment variables through the `env` service, which provides type-safe access to your `.env` file values. You import the env service and call `env.get()` with the variable name. ```ts import env from '#start/env' const apiKey = env.get('RESEND_API_KEY') ``` This pattern keeps your configuration organized and validated. The env service ensures required variables are present and throws clear errors if they're missing. You should never access environment variables directly in your controllers, services, or other application code. Always access them through config files. This creates a single source of truth for configuration. ### Variable interpolation The `.env` file supports variable interpolation, allowing you to reference other environment variables within a value. Use the `$VAR` or `${VAR}` syntax to interpolate variables. ```bash title=".env" HOST=localhost PORT=3333 APP_URL=http://$HOST:$PORT ``` In this example, `APP_URL` resolves to `http://localhost:3333`. This is useful when you need to compose values from other variables without repeating yourself. You can also use curly braces for clarity or when the variable name is adjacent to other characters. ```bash title=".env" S3_BUCKET=my-app-uploads S3_REGION=us-east-1 S3_URL=https://${S3_BUCKET}.s3.${S3_REGION}.amazonaws.com ``` To include a literal dollar sign in a value, escape it with a backslash. ```bash title=".env" PRICE=\$99.99 ``` ### Environment-specific .env files AdonisJS supports multiple `.env` files for different environments and use cases. The framework loads these files in a specific order, with later files overriding earlier ones. | File | Purpose | Git status | |------|---------|------------| | `.env` | Base environment variables for all environments | Ignored | | `.env.local` | Local overrides, loaded in all environments except test | Ignored | | `.env.development` | Development-specific variables | Ignored | | `.env.staging` | Staging-specific variables | Ignored | | `.env.production` | Production-specific variables | Ignored | | `.env.test` | Test-specific variables, loaded when `NODE_ENV=test` | Ignored | | `.env.example` | Template showing required variables with placeholder values | Committed | The loading order depends on your `NODE_ENV` value. For `NODE_ENV=development`, AdonisJS loads files in this order: 1. `.env` (base variables) 2. `.env.development` (environment-specific) 3. `.env.local` (local overrides) For `NODE_ENV=test`, the order is: 1. `.env` (base variables) 2. `.env.test` (test-specific) Note that `.env.local` is not loaded during tests. This prevents local development settings from interfering with test runs. The `.env.example` file serves as documentation for your team. It lists all required environment variables with placeholder values, helping new developers set up their local environment. Unlike other `.env` files, you should commit `.env.example` to version control. ```bash title=".env.example" HOST=0.0.0.0 PORT=3333 APP_KEY= DATABASE_URL=postgresql://user:password@localhost:5432/myapp RESEND_API_KEY= ``` ### Validating environment variables AdonisJS validates environment variables at startup through the `start/env.ts` file. This validation ensures your application won't start with missing or invalid configuration, catching errors early rather than at runtime when they're harder to debug. The `start/env.ts` file uses schema based validation to define which environment variables your application expects, their types, and any constraints. ```ts title="start/env.ts" import { Env } from '@adonisjs/core/env' export default await Env.create(new URL('../', import.meta.url), { /** * Variables for configuring the HTTP server */ HOST: Env.schema.string({ format: 'host' }), PORT: Env.schema.number(), /** * App-specific variables */ APP_KEY: Env.schema.string(), NODE_ENV: Env.schema.enum(['development', 'production', 'test'] as const), /** * Database configuration */ DATABASE_URL: Env.schema.string(), /** * Optional variables with defaults */ LOG_LEVEL: Env.schema.enum(['debug', 'info', 'warn', 'error'] as const).optional(), CACHE_DRIVER: Env.schema.string.optional(), }) ``` When your application starts, AdonisJS reads the `.env` file and validates each variable against this schema. If a required variable is missing or has an invalid value, the application throws a descriptive error and refuses to start. The schema provides several validation methods. :::option{name="Env.schema.string"} Validates the value is a non-empty string. You can validate the string format by speficying one of the following formats. ```ts // Validates the value is a valid hostname or IP Env.schema.string({ format: 'host' }) // Validates the value is a valid URL Env.schema.string({ format: 'url' }) // Validates the value is a valid URL without tld Env.schema.string({ format: 'url', tld: false }) // Validates the value is a valid URL without protocol Env.schema.string({ format: 'url', protocol: false }) // Validates the value is a valid email address Env.schema.string({ format: 'email' }) ``` ::: :::option{name="Env.schema.number"} Validates and casts the value to a number ::: :::option{name="Env.schema.boolean"} Validates and casts the value to a boolean ::: :::option{name="Env.schema.enum"} Validates the value is one of the allowed options ::: :::option{name="Optional chaining"} Any schema method can be made optional by chaining `.optional()`. Optional variables return `undefined` if not set. ```ts title="start/env.ts" import { Env } from '@adonisjs/core/env' export default await Env.create(new URL('../', import.meta.url), { SENTRY_DSN: Env.schema.string.optional(), CACHE_DRIVER: Env.schema.string.optional() }) ``` ::: After validation, the `env` service provides full TypeScript type inference. When you call `env.get('NODE_ENV')`, TypeScript knows the return type is `'development' | 'production' | 'test'` based on your schema definition. ## The adonisrc.ts file The `adonisrc.ts` file configures the framework itself, not your application. While config files define your app's behavior and `.env` stores secrets, `adonisrc.ts` tells AdonisJS how your workspace is structured and which framework features to load. You rarely need to modify this file directly. Most operations that require changes to `adonisrc.ts` are handled automatically by Ace commands. When you run `node ace make:provider` or `node ace make:command`, those commands register the new provider or command in your `adonisrc.ts` file for you. Here's what a basic `adonisrc.ts` file contains. ```ts title="adonisrc.ts" import { defineConfig } from '@adonisjs/core/app' export default defineConfig({ commands: [ () => import('@adonisjs/core/commands'), () => import('@adonisjs/lucid/commands'), ], providers: [ () => import('@adonisjs/core/providers/app_provider'), () => import('@adonisjs/core/providers/hash_provider'), () => import('@adonisjs/core/providers/vinejs_provider'), () => import('@adonisjs/session/session_provider'), () => import('@adonisjs/lucid/database_provider'), () => import('@adonisjs/auth/auth_provider'), ], preloads: [ () => import('#start/routes'), () => import('#start/kernel') ], hooks: { buildStarting: [ () => import('@adonisjs/vite/build_hook') ], }, }) ``` The `providers` array lists all service providers that AdonisJS should load when your application starts. Providers set up framework features like database access, authentication, and session handling. The `commands` array registers Ace commands from packages. Your application's commands in the `commands` directory are automatically discovered, so you only list package commands here. The `hooks` object defines functions that run during specific lifecycle events. The `buildStarting` hook runs when you build your application for production. You can learn more about the available properties in the [AdonisRC file reference guide](../reference/adonisrc_file.md) --- # Deployment This guide covers deploying AdonisJS applications to production. You will learn how to: - Understand the standalone build and why your source files are not needed in production - Configure `NODE_ENV` correctly during build and runtime - Create a production build using the `node ace build` command - Configure static files to be copied to the build output - Run your application in production - Handle user-uploaded files with persistent storage - Configure logging for production environments - Run database migrations safely in production - Create a Docker image using a multi-stage Dockerfile ## Overview AdonisJS applications are written in TypeScript and must be compiled to JavaScript before running in production. The build process creates a **standalone build**, which means the compiled output contains everything needed to run your application without the original TypeScript source files. Since AdonisJS apps run on the Node.js runtime, your deployment platform must support Node.js version 24 or later. The build process compiles your TypeScript code, bundles frontend assets (if using Vite), and copies necessary files to a `build` directory that you can deploy directly to your production server. ## Understanding the standalone build The standalone build is the compiled output of your AdonisJS application. After creating the build, you only need to deploy the `build` directory to your production server. The original source files, development dependencies, and TypeScript configuration are not required in production. This approach offers several benefits. The deployment size is significantly smaller since you are not shipping TypeScript source files or development tooling. The production environment only needs the JavaScript runtime, and you can treat the `build` folder as an independent, self-contained application. ## NODE_ENV during build and runtime The `NODE_ENV` environment variable behaves differently during the build process versus runtime, and understanding this distinction is important for successful deployments. During the build process, you need development dependencies installed because the build tooling (TypeScript compiler, Vite, and other build tools) are typically listed as `devDependencies` in your `package.json`. If you are creating the build in a CI environment or a sandbox where dependencies are not already installed, set `NODE_ENV=development` before running `npm install` to ensure all dependencies are available. ```sh # In CI/CD or fresh environments NODE_ENV=development npm install npm run build ``` During runtime in production, `NODE_ENV` should be set to `production`. This enables production optimizations and disables development-only features like detailed error pages. ## Creating the production build Run the build command from your project root. ```sh npm run build ``` This executes `node ace build` under the hood. The build process performs the following steps in order: 1. Removes the existing `./build` folder if one exists 2. Rewrites the `ace.js` file to remove the TypeScript loader import 3. Compiles frontend assets using Vite (if configured) 4. Compiles TypeScript source code to JavaScript using `tsc` 5. Copies non-TypeScript files registered in the `metaFiles` array to `./build` 6. Copies `package.json` and your package manager lock file to `./build` If there are TypeScript errors in your code, the build will fail. You must fix these errors before creating a production build. If you need to bypass TypeScript errors temporarily, use the `--ignore-ts-errors` flag. ```sh npm run build -- --ignore-ts-errors ``` The `--package-manager` flag controls which lock file is copied to the build output. If not specified, the build command detects your package manager based on how you invoked the command (for example, `npm run build` versus `pnpm build`). ```sh npm run build -- --package-manager=pnpm ``` ## Build output contents After a successful build, the `build` directory contains your compiled application. Here is what you will find inside: - Compiled JavaScript files mirroring your source directory structure - The `package.json` and lock file for installing production dependencies - Static files and other assets configured in `metaFiles` - Frontend assets in `build/public` (for Vite-powered applications) Environment files (`.env`, `.env.example`) are intentionally excluded from the build output. Environment variables are not portable between environments, and you must configure them separately for each deployment target through your hosting platform's environment variable management. ## Static files Static files that need to be included in the production build are configured using the `metaFiles` array in your `adonisrc.ts` file. These are non-TypeScript files that your application needs at runtime, such as Edge templates or public assets. ```ts title="adonisrc.ts" { metaFiles: [ { pattern: 'resources/views/**/*.edge', reloadServer: false, }, { pattern: 'public/**', reloadServer: false, }, ], } ``` The `pattern` property accepts glob patterns to match files. The `reloadServer` property controls whether file changes trigger a server restart during development and has no effect on the production build. For Hypermedia and Inertia applications, Vite compiles frontend assets and places them in the `public` directory. These are then copied to `build/public` during the build process. ### Adjust `keepAliveTimeout` for reverse proxy and node balancers Real-world apps are usually not accessed directly, but behind reverse proxy and node balancers, e.g. Nginx. By default, Node.js closes idle connections after 5 seconds, but Nginx may try to keep them open for 60+ seconds. When Nginx tries to reuse an old connection it thinks is open, but Node.js has already silently closed it, Nginx throws `502 Bad Gateway` errors. To avoid this issue, you need to change AdonisJS server's `keepAliveTimeout` to larger than Nginx's `proxy_read_timeout` (50s by default). ```ts title="config/app.ts" { keepAliveTimeout: 55000, } ``` ### Serving static files in production While AdonisJS includes a [static file server](../guides/basics/static_file_server.md), you should offload static file serving to a dedicated tool in production. Every static file request handled by your Node.js process is a request that cannot be spent on dynamic work. A reverse proxy or CDN is purpose-built for this job and will deliver files faster with less resource usage. You have two main options depending on your infrastructure. **Reverse proxy (Nginx, Caddy, Traefik, Apache)** Configure your reverse proxy to serve the `build/public` directory directly for static file requests and forward everything else to your AdonisJS server. This way, static files never reach your Node.js process. With Nginx, you can add a `location` block that tries to serve files from the `public` directory first and falls back to the AdonisJS server for dynamic routes. ```nginx title="nginx.conf" server { listen 80; server_name example.com; root /path/to/your/app/build/public; location / { try_files $uri @adonis; } location @adonis { proxy_pass http://localhost:3333; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; } } ``` **CDN** For the best performance, upload your compiled assets to a CDN. This requires updating the `assetsUrl` option in your Vite configuration so that generated asset URLs point to your CDN instead of your application server. ```ts title="config/vite.ts" { assetsUrl: 'https://cdn.example.com/assets', } ``` After each build, deploy the contents of `build/public` to your CDN. Your CI/CD pipeline can automate this step. :::tip You can combine both approaches. Use a reverse proxy to serve files like `favicon.ico` and `robots.txt` from the `public` directory, and a CDN for Vite-compiled assets (JavaScript, CSS, images) that benefit from global distribution. ::: ## Running the production build To run your application in production, treat the `build` directory as the root of your application. Change into the build directory, install production dependencies, and start the server. ```sh cd build npm ci --omit=dev node bin/server.js ``` Using `npm ci --omit=dev` instead of `npm install` ensures a clean, reproducible installation using only the lock file and skips development dependencies. You must also provide all required environment variables before starting the server. Your hosting platform should have a mechanism for configuring environment variables. You can reference your `env.ts` file or `.env.example` to see which variables your application requires. ## User-uploaded files User-uploaded files require persistent storage that survives deployments. When you deploy a new version of your application, the previous build directory is typically replaced, which means any files stored locally within that directory are lost. For production deployments, use a cloud storage provider such as Amazon S3, Cloudflare R2, or Google Cloud Storage. These services provide durable, persistent storage that is independent of your application deployments. If you must store files locally on your server, configure a directory outside of your application's build folder and ensure this directory persists across deployments. The specific approach depends on your hosting platform and deployment strategy. ## Logging AdonisJS uses Pino for logging, which outputs structured logs in NDJSON format to stdout. This format is well-suited for production environments because most logging services and log aggregators can parse NDJSON directly. Configure your hosting platform to capture stdout from your application process. Most platforms provide built-in integrations with logging services like Datadog, Logtail, or Papertrail. Alternatively, you can pipe your application's output to a log shipping agent. Pino's structured JSON output includes timestamps, log levels, and any additional context you attach to log messages, making it straightforward to search and filter logs in your logging service. ## Database migrations Run migrations in production using the `migration:run` command with the `--force` flag. ```sh node ace migration:run --force ``` The `--force` flag is required because running migrations is disabled by default in production environments. This is a safety measure to prevent accidental schema changes. AdonisJS migrations are idempotent and use exclusive locking. If multiple processes attempt to run migrations simultaneously (for example, during a rolling deployment), only one process will execute the migrations while others wait. This prevents race conditions and duplicate migration attempts. If your deployment platform supports lifecycle hooks or has a dedicated mechanism for running database migrations, use that instead of running migrations as part of your application startup. This separation ensures migrations complete successfully before any application instances start serving traffic. Rolling back migrations in production is disabled by default and is not recommended. Instead of rolling back, create a new migration that reverses the changes you need to undo. This approach maintains a clear history of all schema changes and avoids the risks associated with rollbacks in production. ## Dockerfile The following Dockerfile creates an optimized production image using multi-stage builds. The first stage installs all dependencies and creates the build, while the final stage contains only the production runtime. ```dockerfile title="Dockerfile" FROM node:lts-bookworm-slim AS base # ---------------------------- # Stage 1: Install all dependencies # ---------------------------- FROM base AS deps WORKDIR /app COPY package*.json ./ RUN npm ci # ---------------------------- # Stage 2: Build the application # ---------------------------- FROM deps AS build WORKDIR /app COPY . . RUN node ace build # ---------------------------- # Stage 3: Production runtime # ---------------------------- FROM base AS production WORKDIR /app ENV NODE_ENV=production COPY --from=build /app/build ./ RUN npm ci --omit=dev EXPOSE 3333 CMD ["node", "bin/server.js"] ``` This Dockerfile follows a multi-stage pattern where each stage has a specific purpose. The `deps` stage installs all dependencies needed for the build. The `build` stage compiles the TypeScript application. The `production` stage creates a minimal image with only production dependencies. To build and run the Docker image: ```sh docker build -t my-adonis-app . docker run -p 3333:3333 --env-file .env my-adonis-app ``` Pass environment variables using the `--env-file` flag or individual `-e` flags. The container exposes port 3333 by default, which you can map to any host port. ### Running migrations with Docker For containerized deployments, you have two options for running migrations. You can run them as a separate command before starting your application containers: ```sh docker run --rm --env-file .env my-adonis-app node ace migration:run --force ``` Alternatively, you can create an entrypoint script that runs migrations before starting the server. Create this file in your project root: ```js title="docker-entrypoint.js" import { execSync } from 'node:child_process' /** * Run migrations before starting the server * when the MIGRATE environment variable is set. */ if (process.env.MIGRATE === 'true') { console.log('Running migrations...') execSync('node ace migration:run --force', { stdio: 'inherit' }) } /** * Start the server */ console.log('Starting server...') await import('./bin/server.js') ``` Update your Dockerfile to use this entrypoint: ```dockerfile title="Dockerfile" FROM node:lts-bookworm-slim AS base FROM base AS deps WORKDIR /app COPY package*.json ./ RUN npm ci FROM deps AS build WORKDIR /app COPY . . RUN node ace build FROM base AS production WORKDIR /app ENV NODE_ENV=production COPY --from=build /app/build ./ RUN npm ci --omit=dev # [!code ++:2] # Copy the entrypoint script COPY docker-entrypoint.js ./ EXPOSE 3333 # [!code --] CMD ["node", "bin/server.js"] # [!code ++] CMD ["node", "docker-entrypoint.js"] ``` With this setup, setting `MIGRATE=true` as an environment variable will run migrations before starting the server. For horizontal scaling where multiple containers start simultaneously, the migration locking ensures only one container runs migrations while others wait. --- # Frequently Asked Questions ## Is AdonisJS actively maintained? Yes, AdonisJS is actively maintained with regular updates, bug fixes, and feature additions. The framework has been consistently developed since 2015 and receives continuous attention from its core team. You can verify maintenance activity by checking [OSS Insights](https://next.ossinsight.io/analyze/adonisjs), which shows comprehensive metrics across the entire AdonisJS GitHub organization including recent commits, responsive issue discussions, and release patterns. ## How does AdonisJS compare to Express, NestJS, and Fastify? AdonisJS is a full-stack, batteries-included framework that provides complete development solutions out of the box. This contrasts with Express and Fastify's minimalist approach and differs from NestJS's heavily opinionated enterprise architecture. **Compared to Express**: AdonisJS offers built-in features for authentication, validation, ORM, and sessions, whereas Express requires you to choose and integrate these pieces yourself. AdonisJS provides more structure and convention, while Express offers maximum flexibility. **Compared to NestJS**: Both frameworks support TypeScript natively and provide structured architecture. NestJS emphasizes Angular-style decorators and enterprise patterns, while AdonisJS follows Laravel-inspired conventions with a simpler learning curve. AdonisJS is also faster to embrace modern Node.js primitives like ESM, making it more aligned with current JavaScript ecosystem standards. **Compared to Fastify**: Fastify focuses on maximum performance with minimal overhead, while AdonisJS prioritizes developer productivity with comprehensive built-in features. Both deliver excellent performance, but AdonisJS includes more functionality by default. Choose AdonisJS when you want a complete framework with built-in solutions and Laravel-style development experience in Node.js. Choose alternatives when you need maximum flexibility (Express), enterprise patterns (NestJS), or minimal abstraction (Fastify). ## Is AdonisJS production-ready? Yes, AdonisJS is production-ready and has powered thousands of applications since 2015, ranging from small startups to large-scale enterprise systems. The framework handles high-traffic scenarios efficiently and follows security best practices by default. Companies using AdonisJS in production include [Marie Claire](https://www.marieclaire.com/), [Ledger](https://www.ledger.com/), [Kayako](https://kayako.com/), [Renault Group](https://www.renaultgroup.com/en/), [Zakodium](https://www.zakodium.com/), [FIVB](https://www.fivb.com/), [Petpooja](https://www.petpooja.com/), [Paytm](https://paytm.com/), [Verifiables](https://verifiables.com), [Pappers](https://www.pappers.fr/), [Edmond de Rothschild](https://www.edmond-de-rothschild.com/en/home), [France Travail](https://www.francetravail.fr/accueil/), and many more. While production-ready, consider that the AdonisJS community is smaller than Express or Next.js communities, which means fewer Stack Overflow answers and potentially more challenging hiring. However, developers familiar with TypeScript and modern frameworks become productive quickly, and the official documentation is comprehensive. The framework deploys successfully to all major hosting platforms including traditional VPS providers, Docker containers, and modern platforms like Railway, Render, and Fly.io. ## Does AdonisJS support TypeScript natively? Yes, AdonisJS is built with TypeScript from the ground up and provides first-class TypeScript support. Unlike frameworks where TypeScript is an optional add-on, AdonisJS is designed specifically for TypeScript and leverages its full power. When you create a new AdonisJS project, TypeScript is already configured with optimal settings. The build system, type checking, and development workflow work seamlessly without additional setup. Every framework API is fully typed, providing complete IntelliSense and compile-time error checking. The framework uses advanced TypeScript features to infer types automatically, meaning you get type safety without writing excessive type annotations. For example, validation schemas automatically infer the validated data type, and models automatically provide types for all properties and methods. TypeScript compiles away during the build process, so there's no runtime overhead. Your production code runs as optimized JavaScript with the same performance as hand-written JavaScript. ## Who maintains AdonisJS? AdonisJS is primarily maintained by Harminder Virk , who created the framework in 2015 and continues to lead its development. The framework also has a [small core team](https://adonisjs.com/team) of contributors who help with specific areas like documentation, package maintenance, and community support. Harminder works on AdonisJS full-time as his primary professional focus, not as a side project. This ensures consistent attention, timely issue responses, and regular feature development. The framework receives financial support through the [Insiders](https://adonisjs.com/insiders) and [Partners](https://adonisjs.com/partner) programs, enabling sustainable full-time maintenance. While some developers worry about frameworks maintained primarily by one person, this model has proven sustainable for nearly a decade. A single maintainer ensures coherent vision, consistent code quality, and fast decision-making. Many successful open-source projects (Linux, Ruby on Rails, Laravel, Vue.js) have followed similar models successfully. The codebase is well-documented and structured to enable community contributions. The framework is open source under the MIT license, ensuring the code remains accessible regardless of future circumstances. ## Where can I get help with AdonisJS? The primary support channel is the official [Discord server](https://discord.gg/vDcEjq6), where community members and core team typically respond within hours. The server has dedicated channels for different topics including general help, database questions, and deployment issues. For longer-form questions or architectural advice, use [GitHub Discussions](https://github.com/adonisjs/core/discussions). For bug reports and feature requests, use [GitHub Issues](https://github.com/adonisjs/core/issues). The official [documentation](https://docs.adonisjs.com) is comprehensive and answers most common questions. To get better answers faster, provide clear context, share relevant code snippets, include complete error messages, specify your environment (versions), and explain what you've already tried. ## Can I use AdonisJS for building APIs? Yes, AdonisJS is excellent for building APIs and many developers choose it primarily for API development. The framework provides extensive built-in features specifically designed for APIs, including RESTful resource routing, built-in validation with VineJS, multiple authentication schemes, transformers for serializing data, CORS handling, and rate limiting support. --- :variantSelector{} # Building DevShow - A Community showcase website In this tutorial, you will build DevShow. **DevShow is a small community showcase website where users can share what they've built.** Every user can create an account, publish a "showcase entry" (a project, tool, experiment, or anything they're proud of), and browse entries created by others. ## Overview We're taking a hands-on approach in this tutorial by building a real application from start to finish. Instead of learning about features in isolation, you will see how everything in AdonisJS works together: **routing, controllers, models, validation, authentication, and templating all coming together to create a functioning web application**. By the end of this tutorial, you'll have built: - **Post listing and detail pages** - Display all posts and individual post details with comments - **Post creation and editing** - Forms to create and update posts with validation - **Comment system** - Allow users to comment on posts - **Authorization** - Ensure users can only edit/delete their own posts and comments - **Navigation and styling** - Polished UI with proper navigation between pages The authentication system (signup, login, logout) is already included in your starter kit and fully functional. ## Understanding the starter kit We're starting with the AdonisJS Hypermedia starter kit, which already has authentication built in. Let's see what we have to work with by opening the routes file. ```ts title="start/routes.ts" import { middleware } from '#start/kernel' import { controllers } from '#generated/controllers' import router from '@adonisjs/core/services/router' router.on('/').render('pages/home').as('home') /** * Signup and login routes - only accessible to guests */ router .group(() => { router.get('signup', [controllers.NewAccount, 'create']) router.post('signup', [controllers.NewAccount, 'store']) router.get('login', [controllers.Session, 'create']) router.post('login', [controllers.Session, 'store']) }) .use(middleware.guest()) /** * Logout route - only accessible to authenticated users */ router .group(() => { router.post('logout', [controllers.Session, 'destroy']) }) .use(middleware.auth()) ``` The starter kit gives us user signup, login, and logout routes. Notice how `middleware.guest()` ensures only logged-out users can access signup/login, while `middleware.auth()` protects the logout route. :::note We'll use the `auth` middleware throughout the tutorial to protect routes that require authentication. ::: ### How controllers work Let's look at the signup controller to see how requests flow through the application. ```ts title="app/controllers/new_account_controller.ts" import User from '#models/user' import { signupValidator } from '#validators/user' import type { HttpContext } from '@adonisjs/core/http' export default class NewAccountController { async create({ view }: HttpContext) { return view.render('pages/auth/signup') } async store({ request, response, auth }: HttpContext) { /** * Validate the submitted data */ const payload = await request.validateUsing(signupValidator) /** * Create the new user in the database */ const user = await User.create(payload) /** * Log them in automatically */ await auth.use('web').login(user) /** * Redirect to home page */ response.redirect().toRoute('home') } } ``` Each controller method receives an HTTP context object as its first parameter. The context contains everything about the current request: the request data, response object, auth state, view renderer, and more. We destructure just the properties we need (`view` for rendering templates, `request` for form data, `response` for redirects, and `auth` for authentication). The `create` method simply shows the signup form. The `store` method does the heavy lifting. It validates data, creates the user, logs them in, and redirects home. **This pattern of bringing together validators, models, and auth is what you'll see throughout the tutorial**. You might notice the controller references a `User` model and a `signupValidator`. The starter kit already includes these. We'll explore how models work in the [Database and Models](./database_and_models.md) chapter and validators in the [Forms and Validation](./forms_and_validation.md) chapter. ### How views work When a controller calls `view.render('pages/auth/signup')`, AdonisJS looks for a template file and renders it as HTML. Let's see what that signup view looks like. ```edge title="resources/views/pages/auth/signup.edge" @layout()

Signup

Enter your details below to create your account

@form({ route: 'new_account.store', method: 'POST' })
@field.root({ name: 'fullName' }) @!field.label({ text: 'Full name' }) @!input.control() @!field.error() @end
@field.root({ name: 'email' }) @!field.label({ text: 'Email' }) @!input.control({ type: 'email', autocomplete: 'email' }) @!field.error() @end
@field.root({ name: 'password' }) @!field.label({ text: 'Password' }) @!input.control({ type: 'password', autocomplete: 'new-password' }) @!field.error() @end
@field.root({ name: 'passwordConfirmation' }) @!field.label({ text: 'Confirm password' }) @!input.control({ type: 'password', autocomplete: 'new-password' }) @!field.error() @end
@!button({ text: 'Sign up', type: 'submit' })
@end
@end ``` Views live in the `resources/views` directory. AdonisJS uses Edge as its templating engine. Edge templates look similar to HTML but with special tags that start with `@`. The `@layout()` tag wraps the page content with a common layout (header, footer, CSS). The `@form()` and `@field.root()` tags are components that come with the starter kit. They render standard HTML form elements with built-in features like CSRF protection and validation error display. When you visit `/signup`, the route calls the controller's `create` method, which renders this view, and Edge converts it to HTML that your browser displays. ## Try creating an account Before we move forward, start your development server with `node ace serve --hmr` and try creating an account. Get comfortable with how the starter kit works. We'll be building on this foundation. --- :variantSelector{} # Building DevShow - A Community showcase website In this tutorial, you will build DevShow. **DevShow is a small community showcase website where users can share what they've built.** Every user can create an account, publish a "showcase entry" (a project, tool, experiment, or anything they're proud of), and browse entries created by others. ## Overview We're taking a hands-on approach in this tutorial by building a real application from start to finish. Instead of learning about features in isolation, you will see how everything in AdonisJS and React works together: **routing, controllers, models, validation, authentication, transformers, and React components all coming together to create a functioning web application**. By the end of this tutorial, you'll have built: - **Post listing and detail pages** - Display all posts and individual post details with comments - **Post creation and editing** - Forms to create and update posts with validation - **Comment system** - Allow users to comment on posts - **Authorization** - Ensure users can only edit/delete their own posts and comments - **Navigation and styling** - Polished UI with proper navigation between pages The authentication system (signup, login, logout) is already included in your starter kit and fully functional. ## Understanding the starter kit We're starting with the AdonisJS Inertia + React starter kit, which already has authentication built in. Let's see what we have to work with by opening the routes file. ```ts title="start/routes.ts" import { middleware } from '#start/kernel' import { controllers } from '#generated/controllers' import router from '@adonisjs/core/services/router' router.on('/').renderInertia('home') /** * Signup and login routes - only accessible to guests */ router .group(() => { router.get('signup', [controllers.NewAccount, 'create']) router.post('signup', [controllers.NewAccount, 'store']) router.get('login', [controllers.Session, 'create']) router.post('login', [controllers.Session, 'store']) }) .use(middleware.guest()) /** * Logout route - only accessible to authenticated users */ router .group(() => { router.post('logout', [controllers.Session, 'destroy']) }) .use(middleware.auth()) ``` The starter kit gives us user signup, login, and logout routes. Notice how `middleware.guest()` ensures only logged-out users can access signup/login, while `middleware.auth()` protects the logout route. :::note We'll use the `auth` middleware throughout the tutorial to protect routes that require authentication. ::: ### How controllers work with Inertia Let's look at the signup controller to see how requests flow through the application with Inertia. ```ts title="app/controllers/new_account_controller.ts" import User from '#models/user' import { signupValidator } from '#validators/user' import type { HttpContext } from '@adonisjs/core/http' export default class NewAccountController { async create({ inertia }: HttpContext) { return inertia.render('auth/signup') } async store({ request, response, auth }: HttpContext) { /** * Validate the submitted data */ const payload = await request.validateUsing(signupValidator) /** * Create the new user in the database */ const user = await User.create(payload) /** * Log them in automatically */ await auth.use('web').login(user) /** * Redirect to home page */ response.redirect().toRoute('home') } } ``` Each controller method receives an HTTP context object as its first parameter. The context contains everything about the current request: the request data, response object, auth state, Inertia renderer, and more. We destructure just the properties we need (`inertia` for rendering React components, `request` for form data, `response` for redirects, and `auth` for authentication). The `create` method renders the signup form using `inertia.render()`. Instead of returning HTML like traditional server-rendered apps, Inertia sends a JSON response containing the component name and any props. Your React frontend receives this and renders the corresponding component. The `store` method does the heavy lifting. It validates data, creates the user, logs them in, and redirects home. **This pattern of bringing together validators, models, and auth is what you'll see throughout the tutorial**. You might notice the controller references a `User` model and a `signupValidator`. The starter kit already includes these. We'll explore how models work in the [Database and Models](./database_and_models.md) chapter and validators in the [Forms and Validation](./forms_and_validation.md) chapter. ### About Inertia and React If you've worked with meta-frameworks like Next.js or Remix, this starter kit might look unusual. **Most of our code lives in AdonisJS (the backend), and React is only used to render views.** There's no frontend routing, no isomorphic code running on both server and client, and no complex state management libraries. This is intentional. Inertia's philosophy is simple: **keep your backend and frontend separate, but make them work together seamlessly.** - Your **backend** (AdonisJS) handles routing, authentication, database queries, validation, and business logic - Your **frontend** (React) handles rendering and user interactions - **Inertia** acts as the glue, sending JSON responses from your controllers to your React components If you're hearing about Inertia for the first time, you might want to visit [inertiajs.com](https://inertiajs.com) to learn more about its philosophy. Or just power through this tutorial and see for yourself how simple it is compared to the complexity cocktail offered by meta-frameworks. **Here's how a request flows in an AdonisJS + Inertia app:** ``` Browser Request ↓ AdonisJS Router ↓ Controller (validates, queries database, etc.) ↓ inertia.render('component-name', props) ↓ React Component (rendered via Vite) ↓ Browser Response ``` ### How the signup form works When a controller calls `inertia.render('auth/signup')`, Inertia looks for a React component at `inertia/pages/auth/signup.tsx` and renders it. Let's look at that component. ```tsx title="inertia/pages/auth/signup.tsx" import { Form } from '@adonisjs/inertia/react' export default function Signup() { return (

Signup

Enter your details below to create your account

{({ errors }) => ( <>
{errors.fullName &&
{errors.fullName}
}
{errors.email &&
{errors.email}
}
{errors.password &&
{errors.password}
}
{errors.passwordConfirmation &&
{errors.passwordConfirmation}
}
)}
) } ``` Page components live in the `inertia/pages` directory. The `Form` component from `@adonisjs/inertia/react` handles form submissions. It accepts a `route` prop (the named route to submit to) and provides an `errors` object through a render prop pattern. When you submit the form, Inertia sends the request to your backend and automatically handles the response, including displaying validation errors. ## Try creating an account Before we move forward, start your development server with `node ace serve --hmr` and try creating an account. Get comfortable with how the starter kit works. We'll be building on this foundation. --- :variantSelector{} # Command line and REPL You might be wondering why we're covering CLI and REPL instead of jumping straight into building features. Here's why: throughout this tutorial, you'll constantly use Ace commands to generate controllers, models, and other files. Getting familiar with the CLI now prevents us from interrupting the flow later. More importantly, the [REPL](../../../guides/ace/repl.md) will become our playground for experimenting with models and databases. When we explore database queries, filters, and relationships in later sections, we'll use the REPL to try things out. It's a throwaway environment that lets us focus on learning concepts without the ceremony of building complete features. ## Exploring available commands Let's start by seeing what commands AdonisJS gives us. Run this in your terminal. ```bash node ace list ``` You should see something like this: :::media ![](./node_ace_list.png) ::: Notice how the commands are grouped together? - The `make:*` commands help you generate files. - The `migration:*` commands help you run and revert database migrations. - The `db:*` commands handle database seeding, and so on. Want to know more about a specific command? Just add `--help` to the end. This shows you everything that command can do, including any options you can pass to it. ```bash node ace make:controller --help ``` ## Using the REPL The REPL will be our experimentation playground throughout the tutorial. Let's explore how to use it by creating and querying users for our DevShow web-app. ### Start the REPL and load models First, start the REPL: ```bash node ace repl ``` Once the REPL starts, load all your models using the `loadModels()` helper. The REPL provides several built-in helper functions like this to make experimentation easier. This helper will make all your models available under the `models` namespace. ```ts await loadModels() // Access user model models.user ``` ### Create users Let's use the `User` model (stored within the `app/models/user.ts` file) to create a couple of users that we can use to log into our app later. The `create` method accepts the model properties as an object, persists them to the database and returns a model instance. ```ts await models.user.create({ fullName: 'Harminder Virk', email: 'virk@adonisjs.com', password: 'secret' }) ``` Let's create another user. ```typescript await models.user.create({ fullName: 'Jane Doe', email: 'jane@example.com', password: 'secret' }) ``` ### Fetch all users Now that you have created a couple of users, let's fetch them using the `all` method. This method will execute a `SELECT *` query and returns an array containing both users. Each user is a User model instance, not a plain JavaScript object. ```typescript await models.user.all() ``` ### Find and delete a user You can find a user with a given ID using the `find` method. The return value is an instance of the User model or `null` (if no user was found). ```typescript const user = await models.user.find(1) user.id // 1 user.email // 'virk@adonisjs.com' ``` You can delete this user by simply calling the `delete` method on the User instance. ```ts await user.delete() user.$isDeleted // true ``` If you list all users again, you should see only Jane remains: ```ts await models.user.all() ``` ### Exit the REPL When you're done exploring, type `.exit` or press `Ctrl+D` to leave the REPL and return to your terminal. --- :variantSelector{} # Command line and REPL You might be wondering why we're covering CLI and REPL instead of jumping straight into building features. Here's why: throughout this tutorial, you'll constantly use Ace commands to generate controllers, models, and other files. Getting familiar with the CLI now prevents us from interrupting the flow later. More importantly, the [REPL](../../../guides/ace/repl.md) will become our playground for experimenting with models and databases. When we explore database queries, filters, and relationships in later sections, we'll use the REPL to try things out. It's a throwaway environment that lets us focus on learning concepts without the ceremony of building complete features. ## Exploring available commands Let's start by seeing what commands AdonisJS gives us. Run this in your terminal. ```bash node ace list ``` You should see something like this: :::media ![](../hypermedia/node_ace_list.png) ::: Notice how the commands are grouped together? - The `make:*` commands help you generate files. - The `migration:*` commands help you run and revert database migrations. - The `db:*` commands handle database seeding, and so on. Want to know more about a specific command? Just add `--help` to the end. This shows you everything that command can do, including any options you can pass to it. ```bash node ace make:controller --help ``` ## Using the REPL The REPL will be our experimentation playground throughout the tutorial. Let's explore how to use it by creating and querying users for our DevShow web-app. ### Start the REPL and load models First, start the REPL: ```bash node ace repl ``` Once the REPL starts, load all your models using the `loadModels()` helper. The REPL provides several built-in helper functions like this to make experimentation easier. This helper will make all your models available under the `models` namespace. ```ts await loadModels() // Access user model models.user ``` ### Create users Let's use the `User` model (stored within the `app/models/user.ts` file) to create a couple of users that we can use to log into our app later. The `create` method accepts the model properties as an object, persists them to the database and returns a model instance. ```ts await models.user.create({ fullName: 'Harminder Virk', email: 'virk@adonisjs.com', password: 'secret' }) ``` Let's create another user. ```typescript await models.user.create({ fullName: 'Jane Doe', email: 'jane@example.com', password: 'secret' }) ``` ### Fetch all users Now that you have created a couple of users, let's fetch them using the `all` method. This method will execute a `SELECT *` query and returns an array containing both users. Each user is a User model instance, not a plain JavaScript object. ```typescript await models.user.all() ``` ### Find and delete a user You can find a user with a given ID using the `find` method. The return value is an instance of the User model or `null` (if no user was found). ```typescript const user = await models.user.find(1) user.id // 1 user.email // 'virk@adonisjs.com' ``` You can delete this user by simply calling the `delete` method on the User instance. ```ts await user.delete() user.$isDeleted // true ``` If you list all users again, you should see only Jane remains: ```ts await models.user.all() ``` ### Exit the REPL When you're done exploring, type `.exit` or press `Ctrl+D` to leave the REPL and return to your terminal. --- :variantSelector{} # Database and Models In this chapter, you will create models and migrations for the Post and Comment resources, establish relationships between them, generate dummy data using factories and seeders, and query your data using the REPL. ## Overview This chapter introduces Lucid, AdonisJS's SQL ORM. Instead of writing raw SQL queries, you'll work with JavaScript classes called **models** that represent your database tables. Throughout this chapter and the rest of the tutorial, you'll interact with your database exclusively through models. An important distinction: models define how you interact with data, but they don't modify the database structure. That's the job of **migrations**, which create and alter tables. You'll use both as you build DevShow's database structure. :::note **A note on learning:** This chapter introduces several database concepts at once. Don't worry if you don't fully understand everything - the goal is to learn by doing and get something working. Deeper understanding will come with practice. ::: ## Creating the Post model Our app needs posts, so let's create a Post model and its corresponding database migration. In AdonisJS, you create one model per database table. Lucid uses naming conventions to automatically connect models to their tables - a `Post` model maps to a `posts` table, a `User` model maps to a `users` table, and so on. ::::steps :::step{title="Generate the model and migration"} Run this command to create both files at once. ```bash node ace make:model Post -m ``` The `-m` flag tells Ace to create a migration file alongside the model. You'll see this output. ```bash DONE: create app/models/post.ts DONE: create database/migrations/1763866156451_create_posts_table.ts ``` ::: :::step{title="Understanding the generated model"} Let's look at what was generated in the model file. ```ts title="app/models/post.ts" import { PostSchema } from '#database/schema' export default class Post extends PostSchema { } ``` The model extends `PostSchema` — a class that is auto-generated from your database migrations. You don't need to define columns in your model file. When you run migrations, AdonisJS scans your database tables and generates the `database/schema.ts` file with all column definitions, types, and decorators. Your model file is where you add relationships and business logic. ::: :::step{title="Define the table structure in the migration"} Let's update the migration file to define the database table structure. This is where you add columns — the model will pick them up automatically after running the migration. ```ts title="database/migrations/1763866156451_create_posts_table.ts" import { BaseSchema } from '@adonisjs/lucid/schema' export default class extends BaseSchema { protected tableName = 'posts' async up() { this.schema.createTable(this.tableName, (table) => { table.increments('id') // [!code ++:3] table.string('title').notNullable() table.string('url').notNullable() table.text('summary').notNullable() table.timestamp('created_at') table.timestamp('updated_at') }) } async down() { this.schema.dropTable(this.tableName) } } ``` A few important things about migrations: - The `up` method runs when you execute the migration and creates the table. - The `down` method runs when you roll back the migration and drops the table. - Notice that column names in the database use `snake_case` (like `created_at`), while your model properties use `camelCase` (like `createdAt`). Lucid handles this conversion automatically. ::: :::: ## Creating the Comment model Let's create the Comment model following the same process we used for posts. ::::steps :::step{title="Generate the model and migration"} Run this command. ```bash node ace make:model Comment -m ``` You'll see output showing the created files. ```bash DONE: create app/models/comment.ts DONE: create database/migrations/1763866347711_create_comments_table.ts ``` ::: :::step{title="Define the table structure in the migration"} Update the migration to create the comments table with a content column. ```ts title="database/migrations/1763866347711_create_comments_table.ts" import { BaseSchema } from '@adonisjs/lucid/schema' export default class extends BaseSchema { protected tableName = 'comments' async up() { this.schema.createTable(this.tableName, (table) => { table.increments('id') // [!code ++:1] table.text('content').notNullable() table.timestamp('created_at') table.timestamp('updated_at') }) } async down() { this.schema.dropTable(this.tableName) } } ``` ::: :::: ## Running migrations Now let's create these tables in your database by running the migrations. ```bash node ace migration:run ``` You'll see output showing which migrations were executed. ```bash ❯ migrated database/migrations/1763866156451_create_posts_table ❯ migrated database/migrations/1763866347711_create_comments_table ``` Your database now has `posts` and `comments` tables! You'll also notice that `database/schema.ts` has been updated with `PostSchema` and `CommentSchema` classes containing all the column definitions. This file is auto-generated every time you run migrations — you never need to edit it manually. Migrations are tracked in a special `adonis_schema` table in your database. Once a migration runs successfully, it won't run again even if you execute `node ace migration:run` multiple times. ## Adding relationships Right now our posts and comments exist independently, but in our DevShow web-app, comments belong to posts and posts belong to users. We need to establish these connections in our database and models. To create these relationships, we need foreign key columns in our tables. A foreign key is a column that references the primary key of another table. For example, a `post_id` column in the comments table will reference the `id` column in the posts table, linking each comment to its post. Since our tables already exist, we'll create a new migration to add these foreign key columns. ::::steps :::step{title="Create a migration for foreign keys"} The following command will create a new migration file that will modify our existing tables. ```bash node ace make:migration add_foreign_keys_to_posts_and_comments ``` ::: :::step{title="Add foreign key columns"} Update the migration file to add the foreign key columns. ```ts title="database/migrations/1732089800000_add_foreign_keys_to_posts_and_comments.ts" import { BaseSchema } from '@adonisjs/lucid/schema' export default class extends BaseSchema { async up() { /** * Add user_id to posts table */ this.schema.alterTable('posts', (table) => { table.integer('user_id').unsigned().notNullable() table.foreign('user_id').references('users.id').onDelete('CASCADE') }) /** * Add user_id and post_id to comments table */ this.schema.alterTable('comments', (table) => { table.integer('user_id').unsigned().notNullable() table.foreign('user_id').references('users.id').onDelete('CASCADE') table.integer('post_id').unsigned().notNullable() table.foreign('post_id').references('posts.id').onDelete('CASCADE') }) } async down() { this.schema.alterTable('posts', (table) => { table.dropForeign(['user_id']) table.dropColumn('user_id') }) this.schema.alterTable('comments', (table) => { table.dropForeign(['user_id']) table.dropForeign(['post_id']) table.dropColumn('user_id') table.dropColumn('post_id') }) } } ``` A few things to understand about this migration: - We're using `alterTable` instead of `createTable` because we're modifying existing tables. - The foreign key constraints help maintain data integrity by ensuring that a `user_id` or `post_id` always references a valid record in the respective table. - The `onDelete('CASCADE')` means if a user or post is deleted, their comments are automatically deleted too. ::: :::step{title="Run migration"} ```bash node ace migration:run ``` ```bash ❯ migrated database/migrations/1732089800000_add_foreign_keys_to_posts_and_comments ``` ::: :::step{title="Define relationships in the Post model"} Now that the database has the foreign key columns, let's update our models to define these relationships. ```ts title="app/models/post.ts" import { PostSchema } from '#database/schema' // [!code ++:4] import { hasMany, belongsTo } from '@adonisjs/lucid/orm' import type { HasMany, BelongsTo } from '@adonisjs/lucid/types/relations' import Comment from '#models/comment' import User from '#models/user' export default class Post extends PostSchema { // [!code ++:11] /** * A post has many comments */ @hasMany(() => Comment) declare comments: HasMany /** * A post belongs to a user */ @belongsTo(() => User) declare user: BelongsTo } ``` Remember, column definitions like `title`, `url`, `summary`, and `userId` are already handled by `PostSchema` (auto-generated from your migrations). The model file is where you add relationships and business logic. The `@hasMany` decorator defines a one-to-many relationship - one post has many comments. The `@belongsTo` decorator defines the inverse - a post belongs to one user. ::: :::step{title="Define relationships in the Comment model"} ```ts title="app/models/comment.ts" import { CommentSchema } from '#database/schema' // [!code ++:4] import { belongsTo } from '@adonisjs/lucid/orm' import type { BelongsTo } from '@adonisjs/lucid/types/relations' import Post from '#models/post' import User from '#models/user' export default class Comment extends CommentSchema { // [!code ++:11] /** * A comment belongs to a post */ @belongsTo(() => Post) declare post: BelongsTo /** * A comment belongs to a user */ @belongsTo(() => User) declare user: BelongsTo } ``` ::: :::: Perfect! Our models now understand their relationships. When you load a post, you can easily access its comments through `post.comments`, and when you load a comment, you can access its post through `comment.post` or its user through `comment.user`. :::note We haven't added the inverse `hasMany` relationships on the User model (e.g., `user.posts` or `user.comments`) because we don't need them in this tutorial. You could add them later if your application needs to query posts or comments from the user side. ::: ## Creating factories Now that our models and database tables are ready, we need to populate them with dummy data for development and testing. Factories act as blueprints for creating model instances filled with realistic fake data. You define the blueprint once, then generate as many instances as you need with a single line of code. AdonisJS factories use a library called [Faker](https://fakerjs.dev/) to generate realistic data like names, URLs, paragraphs of text, and more. This makes your dummy data look authentic rather than obvious test placeholders. ::::steps :::step{title="Create the Post factory"} ```bash node ace make:factory Post ``` ```bash DONE: create database/factories/post_factory.ts ``` This creates a factory file where we'll define how to generate dummy Post data. ::: :::step{title="Define the Post factory data"} Open the factory file and configure what data to generate for each Post. ```ts title="database/factories/post_factory.ts" import factory from '@adonisjs/lucid/factories' import Post from '#models/post' export const PostFactory = factory .define(Post, async ({ faker }) => { return { title: faker.helpers.arrayElement([ 'My First iOS Weather App', 'Personal Portfolio Website with Dark Mode', 'Real-time Chat Application', 'Expense Tracker Progressive Web App', 'Markdown Blog Engine', 'Recipe Finder with AI Recommendations', '2D Platformer Game in JavaScript', 'Task Management Dashboard', 'URL Shortener with Analytics', 'Fitness Tracking Mobile App', ]), url: faker.internet.url(), summary: faker.lorem.paragraphs(3), } }) .build() ``` - The `faker.helpers.arrayElement()` picks a random title from the array. - The `faker.internet.url()` generates a realistic URL. - The `faker.lorem.paragraphs(3)` creates three paragraphs of placeholder text. ::: :::step{title="Create the Comment factory"} Now let's create a factory for comments using the same process. ```bash node ace make:factory Comment ``` ```bash DONE: create database/factories/comment_factory.ts ``` ::: :::step{title="Define the Comment factory data"} Open the Comment factory and add the data generation logic. ```ts title="database/factories/comment_factory.ts" import factory from '@adonisjs/lucid/factories' import Comment from '#models/comment' export const CommentFactory = factory .define(Comment, async ({ faker }) => { return { content: faker.lorem.paragraph(), } }) .build() ``` The Comment factory is simpler. It only needs to generate the `content` field using `faker.lorem.paragraph()`, which creates a single paragraph of text. We'll handle the `userId` and `postId` relationships in the seeder. ::: :::: ## Creating seeders Factories define HOW to create fake data, but they don't actually create it automatically. That's where seeders come in - they're scripts that use factories to populate your database with actual data. Every time you reset your database or a teammate clones the project, running `node ace db:seed` populates the database with consistent, realistic data. ::::steps :::step{title="Create the seeder"} Let's create a seeder that will generate posts and comments. ```bash node ace make:seeder PostSeeder ``` ```bash DONE: create database/seeders/post_seeder.ts ``` ::: :::step{title="Implement the seeding logic"} Now open the seeder file and add the logic to create posts with comments. ```ts title="database/seeders/post_seeder.ts" import User from '#models/user' import { BaseSeeder } from '@adonisjs/lucid/seeders' import { PostFactory } from '#database/factories/post_factory' import { CommentFactory } from '#database/factories/comment_factory' export default class extends BaseSeeder { async run() { const user = await User.findByOrFail('email', 'jane@example.com') const posts = await PostFactory.merge({ userId: user.id }).createMany(10) for (const post of posts) { await CommentFactory.merge({ postId: post.id, userId: user.id }).createMany( Math.floor(Math.random() * 3) + 3 ) } } } ``` Let's break down what this seeder does: First, we fetch the user with email `jane@example.com`. This is the user we created in the previous chapter when exploring the CLI and REPL. If you followed along, this user should exist in your database. The `findByOrFail` method will throw an error if the user doesn't exist. Next, we use `PostFactory.merge({ userId: user.id }).createMany(10)` to create 10 posts. The `merge()` method is important here - it merges additional data with the factory's generated values. We need this to set the `userId` foreign key on each post. Without it, the foreign key constraint would fail because `userId` would be undefined. Then, for each post, we create between 3 to 5 comments using a similar approach. The formula `Math.floor(Math.random() * 3) + 3` generates a random number between 3 and 5. ::: :::step{title="Run the seeder"} Now execute the seeder to populate your database. ```bash node ace db:seed ``` ```bash ❯ running PostSeeder ``` Your database now has 10 posts, each with several comments! ::: :::: ## Understanding the tools we've used Before we move on to querying data, let's take a moment to understand what we've built. AdonisJS provides dedicated tools for working with databases, and each one has a specific purpose. **Migrations** define your database structure. They create tables, add columns, and establish constraints. Think of them as instructions that transform your database schema. When you run `node ace migration:run`, these instructions execute and modify your database structure. They also auto-generate the `database/schema.ts` file with column definitions for your models. **Models** are your JavaScript interface to database tables. They extend auto-generated schema classes (like `PostSchema`) that contain column definitions, so you only need to add relationships and business logic. Models provide a clean, type-safe API for querying and manipulating data without writing raw SQL. **Factories** generate realistic dummy data for your models. Instead of manually creating test data over and over, you define a blueprint once, and the factory creates as many realistic instances as you need. This is invaluable during development and testing. **Seeders** are scripts that populate your database with data. They typically use factories to generate data, but can also create specific records or import data from other sources. Running `node ace db:seed` executes all your seeders and gives you a consistent database state. These tools work together: migrations shape the database, models interact with it, factories generate data, and seeders populate it. Each tool is focused on its specific job, making your database workflow organized and maintainable. ## Querying data with the REPL Now that we have data in our database, let's explore it using AdonisJS's REPL (Read-Eval-Print Loop). The REPL is an interactive shell where you can run JavaScript code and interact with your models in real-time. ### Start the REPL and load models First, start the REPL. ```bash node ace repl ``` Once the REPL starts, load all your models. ```ts await loadModels() ``` This makes all your models available under the `models` object. ### Fetch all posts Let's fetch all posts from the database. ```ts await models.post.all() ``` You'll see an array of all 10 posts with their data. ```ts [ Post { id: 1, title: 'My First iOS Weather App', url: 'https://example.com/fp', summary: 'Lorem ipsum dolor sit amet...', userId: 1, createdAt: DateTime { ... }, updatedAt: DateTime { ... } }, // ... 9 more posts ] ``` Each post is a Post model instance, not a plain JavaScript object. ### Search posts by title Let's search for posts containing "Task Management" in the title using the `query()` method. ```ts await models.post.query().where('title', 'like', '%Task Management%') ``` The `query()` method returns a chainable query builder built on top of Knex, giving you powerful SQL query capabilities while staying in JavaScript. You'll see an array of matching posts, which might be just one or zero depending on what the factory generated. ### Fetch a post and load its comments Now let's demonstrate how to work with relationships. First, fetch a specific post by its ID. ```ts const post = await models.post.find(1) ``` The post is loaded, but its comments aren't loaded yet (relationships are lazy-loaded by default). Use the `load` method to load the comments relationship. ```ts await post.load('comments') ``` Now you can access the comments. ```ts post.comments ``` You'll see all the comments that belong to this post. ### Load relationships efficiently with preload You can also load relationships when initially fetching the post. ```ts const postWithComments = await models.post.query().preload('comments').first() ``` This fetches the first post and its comments in a single operation. The `preload` method is more efficient than loading relationships separately because it avoids the N+1 query problem. Instead of making one query for the post and then one query per comment, it makes just two queries total. ### Exit the REPL When you're done exploring, type `.exit` to leave the REPL and return to your terminal. ## What you learned You now know how to: - Create models and migrations using the Ace CLI - Create database tables and modify them with migrations - Understand how column definitions are auto-generated in `database/schema.ts` from your migrations - Define relationships between models using `hasMany` and `belongsTo` - Generate dummy data with factories and seeders - Query data using the REPL and model methods - Use the query builder for complex queries - Load relationships with `load()` and `preload()` --- :variantSelector{} # Database and Models In this chapter, you will create models and migrations for the Post and Comment resources, establish relationships between them, generate dummy data using factories and seeders, and query your data using the REPL. ## Overview This chapter introduces Lucid, AdonisJS's SQL ORM. Instead of writing raw SQL queries, you'll work with JavaScript classes called **models** that represent your database tables. Throughout this chapter and the rest of the tutorial, you'll interact with your database exclusively through models. An important distinction: models define how you interact with data, but they don't modify the database structure. That's the job of **migrations**, which create and alter tables. You'll use both as you build DevShow's database structure. :::note **A note on learning:** This chapter introduces several database concepts at once. Don't worry if you don't fully understand everything - the goal is to learn by doing and get something working. Deeper understanding will come with practice. ::: ## Creating the Post model Our app needs posts, so let's create a Post model and its corresponding database migration. In AdonisJS, you create one model per database table. Lucid uses naming conventions to automatically connect models to their tables - a `Post` model maps to a `posts` table, a `User` model maps to a `users` table, and so on. ::::steps :::step{title="Generate the model and migration"} Run this command to create both files at once. ```bash node ace make:model Post -m ``` The `-m` flag tells Ace to create a migration file alongside the model. You'll see this output. ```bash DONE: create app/models/post.ts DONE: create database/migrations/1763866156451_create_posts_table.ts ``` ::: :::step{title="Understanding the generated model"} Let's look at what was generated in the model file. ```ts title="app/models/post.ts" import { PostSchema } from '#database/schema' export default class Post extends PostSchema { } ``` The model extends `PostSchema` — a class that is auto-generated from your database migrations. You don't need to define columns in your model file. When you run migrations, AdonisJS scans your database tables and generates the `database/schema.ts` file with all column definitions, types, and decorators. Your model file is where you add relationships and business logic. ::: :::step{title="Define the table structure in the migration"} Let's update the migration file to define the database table structure. This is where you add columns — the model will pick them up automatically after running the migration. ```ts title="database/migrations/1763866156451_create_posts_table.ts" import { BaseSchema } from '@adonisjs/lucid/schema' export default class extends BaseSchema { protected tableName = 'posts' async up() { this.schema.createTable(this.tableName, (table) => { table.increments('id') // [!code ++:3] table.string('title').notNullable() table.string('url').notNullable() table.text('summary').notNullable() table.timestamp('created_at') table.timestamp('updated_at') }) } async down() { this.schema.dropTable(this.tableName) } } ``` A few important things about migrations: - The `up` method runs when you execute the migration and creates the table. - The `down` method runs when you roll back the migration and drops the table. - Notice that column names in the database use `snake_case` (like `created_at`), while your model properties use `camelCase` (like `createdAt`). Lucid handles this conversion automatically. ::: :::: ## Creating the Comment model Let's create the Comment model following the same process we used for posts. ::::steps :::step{title="Generate the model and migration"} Run this command. ```bash node ace make:model Comment -m ``` You'll see output showing the created files. ```bash DONE: create app/models/comment.ts DONE: create database/migrations/1763866347711_create_comments_table.ts ``` ::: :::step{title="Define the table structure in the migration"} Update the migration to create the comments table with a content column. ```ts title="database/migrations/1763866347711_create_comments_table.ts" import { BaseSchema } from '@adonisjs/lucid/schema' export default class extends BaseSchema { protected tableName = 'comments' async up() { this.schema.createTable(this.tableName, (table) => { table.increments('id') // [!code ++:1] table.text('content').notNullable() table.timestamp('created_at') table.timestamp('updated_at') }) } async down() { this.schema.dropTable(this.tableName) } } ``` ::: :::: ## Running migrations Now let's create these tables in your database by running the migrations. ```bash node ace migration:run ``` You'll see output showing which migrations were executed. ```bash ❯ migrated database/migrations/1763866156451_create_posts_table ❯ migrated database/migrations/1763866347711_create_comments_table ``` Your database now has `posts` and `comments` tables! You'll also notice that `database/schema.ts` has been updated with `PostSchema` and `CommentSchema` classes containing all the column definitions. This file is auto-generated every time you run migrations — you never need to edit it manually. Migrations are tracked in a special `adonis_schema` table in your database. Once a migration runs successfully, it won't run again even if you execute `node ace migration:run` multiple times. ## Adding relationships Right now our posts and comments exist independently, but in our DevShow web-app, comments belong to posts and posts belong to users. We need to establish these connections in our database and models. To create these relationships, we need foreign key columns in our tables. A foreign key is a column that references the primary key of another table. For example, a `post_id` column in the comments table will reference the `id` column in the posts table, linking each comment to its post. Since our tables already exist, we'll create a new migration to add these foreign key columns. ::::steps :::step{title="Create a migration for foreign keys"} The following command will create a new migration file that will modify our existing tables. ```bash node ace make:migration add_foreign_keys_to_posts_and_comments ``` ::: :::step{title="Add foreign key columns"} Update the migration file to add the foreign key columns. ```ts title="database/migrations/1732089800000_add_foreign_keys_to_posts_and_comments.ts" import { BaseSchema } from '@adonisjs/lucid/schema' export default class extends BaseSchema { async up() { /** * Add user_id to posts table */ this.schema.alterTable('posts', (table) => { table.integer('user_id').unsigned().notNullable() table.foreign('user_id').references('users.id').onDelete('CASCADE') }) /** * Add user_id and post_id to comments table */ this.schema.alterTable('comments', (table) => { table.integer('user_id').unsigned().notNullable() table.foreign('user_id').references('users.id').onDelete('CASCADE') table.integer('post_id').unsigned().notNullable() table.foreign('post_id').references('posts.id').onDelete('CASCADE') }) } async down() { this.schema.alterTable('posts', (table) => { table.dropForeign(['user_id']) table.dropColumn('user_id') }) this.schema.alterTable('comments', (table) => { table.dropForeign(['user_id']) table.dropForeign(['post_id']) table.dropColumn('user_id') table.dropColumn('post_id') }) } } ``` A few things to understand about this migration: - We're using `alterTable` instead of `createTable` because we're modifying existing tables. - The foreign key constraints help maintain data integrity by ensuring that a `user_id` or `post_id` always references a valid record in the respective table. - The `onDelete('CASCADE')` means if a user or post is deleted, their comments are automatically deleted too. ::: :::step{title="Run migration"} ```bash node ace migration:run ``` ```bash ❯ migrated database/migrations/1732089800000_add_foreign_keys_to_posts_and_comments ``` ::: :::step{title="Define relationships in the Post model"} Now that the database has the foreign key columns, let's update our models to define these relationships. ```ts title="app/models/post.ts" import { PostSchema } from '#database/schema' // [!code ++:4] import { hasMany, belongsTo } from '@adonisjs/lucid/orm' import type { HasMany, BelongsTo } from '@adonisjs/lucid/types/relations' import Comment from '#models/comment' import User from '#models/user' export default class Post extends PostSchema { // [!code ++:11] /** * A post has many comments */ @hasMany(() => Comment) declare comments: HasMany /** * A post belongs to a user */ @belongsTo(() => User) declare user: BelongsTo } ``` Remember, column definitions like `title`, `url`, `summary`, and `userId` are already handled by `PostSchema` (auto-generated from your migrations). The model file is where you add relationships and business logic. The `@hasMany` decorator defines a one-to-many relationship - one post has many comments. The `@belongsTo` decorator defines the inverse - a post belongs to one user. ::: :::step{title="Define relationships in the Comment model"} ```ts title="app/models/comment.ts" import { CommentSchema } from '#database/schema' // [!code ++:4] import { belongsTo } from '@adonisjs/lucid/orm' import type { BelongsTo } from '@adonisjs/lucid/types/relations' import Post from '#models/post' import User from '#models/user' export default class Comment extends CommentSchema { // [!code ++:11] /** * A comment belongs to a post */ @belongsTo(() => Post) declare post: BelongsTo /** * A comment belongs to a user */ @belongsTo(() => User) declare user: BelongsTo } ``` ::: :::: Perfect! Our models now understand their relationships. When you load a post, you can easily access its comments through `post.comments`, and when you load a comment, you can access its post through `comment.post` or its user through `comment.user`. :::note We haven't added the inverse `hasMany` relationships on the User model (e.g., `user.posts` or `user.comments`) because we don't need them in this tutorial. You could add them later if your application needs to query posts or comments from the user side. ::: ## Creating factories Now that our models and database tables are ready, we need to populate them with dummy data for development and testing. Factories act as blueprints for creating model instances filled with realistic fake data. You define the blueprint once, then generate as many instances as you need with a single line of code. AdonisJS factories use a library called [Faker](https://fakerjs.dev/) to generate realistic data like names, URLs, paragraphs of text, and more. This makes your dummy data look authentic rather than obvious test placeholders. ::::steps :::step{title="Create the Post factory"} ```bash node ace make:factory Post ``` ```bash DONE: create database/factories/post_factory.ts ``` This creates a factory file where we'll define how to generate dummy Post data. ::: :::step{title="Define the Post factory data"} Open the factory file and configure what data to generate for each Post. ```ts title="database/factories/post_factory.ts" import factory from '@adonisjs/lucid/factories' import Post from '#models/post' export const PostFactory = factory .define(Post, async ({ faker }) => { return { title: faker.helpers.arrayElement([ 'My First iOS Weather App', 'Personal Portfolio Website with Dark Mode', 'Real-time Chat Application', 'Expense Tracker Progressive Web App', 'Markdown Blog Engine', 'Recipe Finder with AI Recommendations', '2D Platformer Game in JavaScript', 'Task Management Dashboard', 'URL Shortener with Analytics', 'Fitness Tracking Mobile App', ]), url: faker.internet.url(), summary: faker.lorem.paragraphs(3), } }) .build() ``` - The `faker.helpers.arrayElement()` picks a random title from the array. - The `faker.internet.url()` generates a realistic URL. - The `faker.lorem.paragraphs(3)` creates three paragraphs of placeholder text. ::: :::step{title="Create the Comment factory"} Now let's create a factory for comments using the same process. ```bash node ace make:factory Comment ``` ```bash DONE: create database/factories/comment_factory.ts ``` ::: :::step{title="Define the Comment factory data"} Open the Comment factory and add the data generation logic. ```ts title="database/factories/comment_factory.ts" import factory from '@adonisjs/lucid/factories' import Comment from '#models/comment' export const CommentFactory = factory .define(Comment, async ({ faker }) => { return { content: faker.lorem.paragraph(), } }) .build() ``` The Comment factory is simpler. It only needs to generate the `content` field using `faker.lorem.paragraph()`, which creates a single paragraph of text. We'll handle the `userId` and `postId` relationships in the seeder. ::: :::: ## Creating seeders Factories define HOW to create fake data, but they don't actually create it automatically. That's where seeders come in - they're scripts that use factories to populate your database with actual data. Every time you reset your database or a teammate clones the project, running `node ace db:seed` populates the database with consistent, realistic data. ::::steps :::step{title="Create the seeder"} Let's create a seeder that will generate posts and comments. ```bash node ace make:seeder PostSeeder ``` ```bash DONE: create database/seeders/post_seeder.ts ``` ::: :::step{title="Implement the seeding logic"} Now open the seeder file and add the logic to create posts with comments. ```ts title="database/seeders/post_seeder.ts" import User from '#models/user' import { BaseSeeder } from '@adonisjs/lucid/seeders' import { PostFactory } from '#database/factories/post_factory' import { CommentFactory } from '#database/factories/comment_factory' export default class extends BaseSeeder { async run() { const user = await User.findByOrFail('email', 'jane@example.com') const posts = await PostFactory.merge({ userId: user.id }).createMany(10) for (const post of posts) { await CommentFactory.merge({ postId: post.id, userId: user.id }).createMany( Math.floor(Math.random() * 3) + 3 ) } } } ``` Let's break down what this seeder does: First, we fetch the user with email `jane@example.com`. This is the user we created in the previous chapter when exploring the CLI and REPL. If you followed along, this user should exist in your database. The `findByOrFail` method will throw an error if the user doesn't exist. Next, we use `PostFactory.merge({ userId: user.id }).createMany(10)` to create 10 posts. The `merge()` method is important here - it merges additional data with the factory's generated values. We need this to set the `userId` foreign key on each post. Without it, the foreign key constraint would fail because `userId` would be undefined. Then, for each post, we create between 3 to 5 comments using a similar approach. The formula `Math.floor(Math.random() * 3) + 3` generates a random number between 3 and 5. ::: :::step{title="Run the seeder"} Now execute the seeder to populate your database. ```bash node ace db:seed ``` ```bash ❯ running PostSeeder ``` Your database now has 10 posts, each with several comments! ::: :::: ## Understanding the tools we've used Before we move on to querying data, let's take a moment to understand what we've built. AdonisJS provides dedicated tools for working with databases, and each one has a specific purpose. **Migrations** define your database structure. They create tables, add columns, and establish constraints. Think of them as instructions that transform your database schema. When you run `node ace migration:run`, these instructions execute and modify your database structure. As a bonus, AdonisJS automatically generates the `database/schema.ts` file with column definitions for your models. **Models** are your JavaScript interface to database tables. They extend auto-generated schema classes that provide column definitions, and you add relationships and business logic on top. Models give you a clean, type-safe API for querying and manipulating data without writing raw SQL. **Factories** generate realistic dummy data for your models. Instead of manually creating test data over and over, you define a blueprint once, and the factory creates as many realistic instances as you need. This is invaluable during development and testing. **Seeders** are scripts that populate your database with data. They typically use factories to generate data, but can also create specific records or import data from other sources. Running `node ace db:seed` executes all your seeders and gives you a consistent database state. These tools work together: migrations shape the database, models interact with it, factories generate data, and seeders populate it. Each tool is focused on its specific job, making your database workflow organized and maintainable. ## Querying data with the REPL Now that we have data in our database, let's explore it using AdonisJS's REPL (Read-Eval-Print Loop). The REPL is an interactive shell where you can run JavaScript code and interact with your models in real-time. ### Start the REPL and load models First, start the REPL. ```bash node ace repl ``` Once the REPL starts, load all your models. ```ts await loadModels() ``` This makes all your models available under the `models` object. ### Fetch all posts Let's fetch all posts from the database. ```ts await models.post.all() ``` You'll see an array of all 10 posts with their data. ```ts [ Post { id: 1, title: 'My First iOS Weather App', url: 'https://example.com/fp', summary: 'Lorem ipsum dolor sit amet...', userId: 1, createdAt: DateTime { ... }, updatedAt: DateTime { ... } }, // ... 9 more posts ] ``` Each post is a Post model instance, not a plain JavaScript object. ### Search posts by title Let's search for posts containing "Task Management" in the title using the `query()` method. ```ts await models.post.query().where('title', 'like', '%Task Management%') ``` The `query()` method returns a chainable query builder built on top of Knex, giving you powerful SQL query capabilities while staying in JavaScript. You'll see an array of matching posts, which might be just one or zero depending on what the factory generated. ### Fetch a post and load its comments Now let's demonstrate how to work with relationships. First, fetch a specific post by its ID. ```ts const post = await models.post.find(1) ``` The post is loaded, but its comments aren't loaded yet (relationships are lazy-loaded by default). Use the `load` method to load the comments relationship. ```ts await post.load('comments') ``` Now you can access the comments. ```ts post.comments ``` You'll see all the comments that belong to this post. ### Load relationships efficiently with preload You can also load relationships when initially fetching the post. ```ts const postWithComments = await models.post.query().preload('comments').first() ``` This fetches the first post and its comments in a single operation. The `preload` method is more efficient than loading relationships separately because it avoids the N+1 query problem. Instead of making one query for the post and then one query per comment, it makes just two queries total. ### Exit the REPL When you're done exploring, type `.exit` to leave the REPL and return to your terminal. ## What you learned You now know how to: - Create models and migrations using the Ace CLI - Define column properties on models using decorators - Create database tables and modify them with migrations - Define relationships between models using `hasMany` and `belongsTo` - Generate dummy data with factories and seeders - Query data using the REPL and model methods - Use the query builder for complex queries - Load relationships with `load()` and `preload()` --- :variantSelector{} # Routes, controllers and views In the previous chapter, we created the Post and Comment models with their database tables and relationships. Now we'll bring those models to life by building pages where users can actually see posts. :::note This tutorial covers basic routing, controllers, and views. For advanced topics like route groups, middleware, named routes, route parameters validation, and Edge template components, see the [Routing guide](../../../guides/basics/routing.md), [Controllers guide](../../../guides/basics/controllers.md), and [Edge documentation](https://edgejs.dev). ::: ## Overview Right now, your posts and comments exist only in the database. Let's build two pages: one that lists all posts, and another that shows a single post with its comments. This is where you'll see the complete MVC (Model-View-Controller) pattern in action — **models handle data**, **controllers coordinate logic**, and **views display everything to users**. Before we begin, make sure your development server is running. ```bash node ace serve --hmr ``` ## Displaying the posts list Let's build the complete feature for displaying a list of posts. We'll create a controller, add a method to fetch posts, register a route, and create the view template. ::::steps :::step{title="Creating the controller"} Start by creating a controller to handle posts-related requests. Run this command. ```bash node ace make:controller posts ``` This creates a new file at `app/controllers/posts_controller.ts`. Open it up and you'll see a basic controller class. Let's add a method to list all posts. ```ts title="app/controllers/posts_controller.ts" import Post from '#models/post' import { type HttpContext } from '@adonisjs/core/http' export default class PostsController { // [!code ++:8] async index({ view }: HttpContext) { const posts = await Post .query() .preload('user') .orderBy('createdAt', 'desc') return view.render('posts/index', { posts }) } } ``` A few things to note here: - We're preloading the `user` relationship so we can display the author's name without extra queries - We're ordering posts by creation date with newest first - And passing the posts to a view template called `posts/index`. ::: :::step{title="Defining the route"} Open your routes file and register a route. ```ts title="start/routes.ts" import router from '@adonisjs/core/services/router' import { controllers } from '#generated/controllers' router.on('/').render('pages/home').as('home') // [!code ++] router.get('/posts', [controllers.Posts, 'index']) ``` The route definition connects the `/posts` URL to your controller's `index` method. When someone visits `/posts`, AdonisJS will call `PostsController.index()` and return whatever that method returns. :::note The `#generated/controllers` import is automatically generated by AdonisJS and provides type-safe references to your controllers. The development server watches for new controllers and regenerates this file automatically — this is why the dev server must be running when you create new controllers. For more details on how this works, see the [Controllers guide](../../../guides/basics/controllers.md#the-barrel-file). ::: ::: :::step{title="Creating the view template"} Time to create the view template. ```bash node ace make:view posts/index ``` This creates a new file at `resources/views/posts/index.edge`. Open it and add the following code inside it. ```edge title="resources/views/posts/index.edge" @layout()

Posts

@each(post in posts)

{{ post.title }}

@end
@end ``` This template uses the existing `layout` component that came with your starter kit. The layout handles the basic HTML structure, and you provide the main content by wrapping it in `@layout` tag. Inside, we loop through each post with `@each` and display its title, the author's name, and a link to view post comments. ::: :::: Visit [`/posts`](http://localhost:3333/posts) and you should see a list of all your posts! ## Displaying a single post Now let's add the ability to view an individual post with its details. We'll implement the controller method, register the route with a dynamic parameter, and create the view template. ::::steps :::step{title="Implementing the controller method"} Add the `show` method to your controller. ```ts title="app/controllers/posts_controller.ts" import Post from '#models/post' import type { HttpContext } from '@adonisjs/core/http' export default class PostsController { async index({ view }: HttpContext) { const posts = await Post .query() .preload('user') .orderBy('createdAt', 'desc') return view.render('posts/index', { posts }) } // [!code ++:9] async show({ params, view }: HttpContext) { const post = await Post .query() .where('id', params.id) .preload('user') .firstOrFail() return view.render('posts/show', { post }) } } ``` We're using `firstOrFail()` here, which will automatically throw a 404 error if no post exists with that ID. No need to manually check if the post exists—AdonisJS handles that for you. ::: :::step{title="Registering the route"} Now let's register the route for this controller method. ```ts title="start/routes.ts" import router from '@adonisjs/core/services/router' import { controllers } from '#generated/controllers' router.get('/posts', [controllers.Posts, 'index']) // [!code ++] router.get('/posts/:id', [controllers.Posts, 'show']) ``` - The `:id` part is a route parameter. - When someone visits `/posts/5`, AdonisJS captures that `5` and makes it available in your controller as `params.id`. - You can name the parameter anything you want, `:id`, `:postId`, `:slug` — just be consistent when accessing it. ::: :::step{title="Creating the view template"} Create the view template for displaying a single post. ```bash node ace make:view posts/show ``` This creates `resources/views/posts/show.edge`. Open it and add the following code. ```edge title="resources/views/posts/show.edge" @layout()

{{ post.title }}

{{ post.summary }}
@end ``` Try clicking on a post from your [list page](http://localhost:3333/posts). You should now see the full post with its title, author, and content. ::: :::: ## Using named routes Right now, we're hardcoding URLs like `/posts/{{ post.id }}` in our templates. This works, but what happens if we decide to change our URL pattern from `/posts/:id` to `/showcase/:id`? We'd have to find and replace every hardcoded URL throughout our application. This is where **named routes** come in. Named routes let you assign a unique name to each route, then reference that name when building URLs. If the URL pattern changes, you only update it in one place (the route definition), and all your links automatically work with the new pattern. When you use a controller in your route definition, AdonisJS automatically generates a route name based on the controller and method names. For example: - The `[controllers.Posts, 'index']` gets the name `posts.index`. - And `[controllers.Posts, 'show']` gets the name `posts.show`. You can also manually name routes using the `.as()` method, which is useful for routes that don't use controllers or when you want a custom name. **Named routes can be referenced across your entire application:** - In templates using the `urlFor()` helper - In the `@form` component's `route` parameter - In the `@link` component's `route` parameter - In controllers using `response.redirect().toRoute()` - And more For the complete guide on URL building and named routes, see the [URL builder documentation](../../../guides/basics/url_builder.md). Let's update our posts listing page to use named routes. ```edge title="resources/views/posts/index.edge" @layout()

Posts

@each(post in posts)

{{ post.title }}

@end
@end ``` ## Adding comments to the post view Finally, let's display the comments for each post. First, we need to preload the comments and their authors in the controller. ```ts title="app/controllers/posts_controller.ts" import Post from '#models/post' import { HttpContext } from '@adonisjs/core/http' export default class PostsController { async index({ view }: HttpContext) { const posts = await Post.query().preload('user').orderBy('createdAt', 'desc') return view.render('posts/index', { posts }) } async show({ params, view }: HttpContext) { const post = await Post.query() .where('id', params.id) .preload('user') // [!code ++:3] .preload('comments', (query) => { query.preload('user').orderBy('createdAt', 'asc') }) .firstOrFail() return view.render('posts/show', { post }) } } ``` We're preloading comments along with each comment's user (the author), and ordering them by creation date with oldest first. Now update the view to display them. ```edge title="resources/views/posts/show.edge" @layout()

{{ post.title }}

{{ post.summary }}
// [!code ++:16]

Comments

@each(comment in post.comments)

{{ comment.content }}

By {{ comment.user.fullName }} on {{ comment.createdAt.toFormat('MMM dd, yyyy') }}
@else

No comments yet.

@end
@end ``` Refresh your post detail page and you'll now see all the comments listed below the post content! ## What you've built You've just completed the full MVC flow in AdonisJS: - **Routes** that map URLs to controller actions - **Controllers** that fetch data from your models and pass it to views - **Views** that display data using Edge templates - **Relationships** that let you eager load related data efficiently - **Named routes** that make your templates maintainable --- :variantSelector{} # Routes, controllers and views In the previous chapter, we created the Post and Comment models with their database tables and relationships. Now we'll bring those models to life by building pages where users can actually see posts. :::note This tutorial covers basic routing, controllers, and views. For advanced topics like route groups, middleware, route parameters validation, and custom Inertia components, see the [Routing guide](../../../guides/basics/routing.md), [Controllers guide](../../../guides/basics/controllers.md), and [Inertia documentation](https://inertiajs.com). ::: ## Overview Right now, your posts and comments exist only in the database. Let's build two pages: one that lists all posts, and another that shows a single post with its comments. This is where you'll see the complete flow in action — **models handle data**, **transformers serialize it for the frontend**, **controllers coordinate logic**, and **React components display everything to users**. Before we begin, make sure your development server is running. ```bash node ace serve --hmr ``` ## Displaying the posts list Let's build the complete feature for displaying a list of posts. We'll create a transformer to serialize post data, add a controller method to fetch posts, register a route, and create the React component. ::::steps :::step{title="Creating the transformer"} Transformers convert your Lucid models into plain JSON objects that can be safely sent to your React frontend. They explicitly control what data gets serialized and generate TypeScript types for your components. Create a transformer for posts: ```bash node ace make:transformer post ``` This creates `app/transformers/post_transformer.ts`. Open it and define what data to serialize: ```ts title="app/transformers/post_transformer.ts" import { BaseTransformer } from '@adonisjs/core/transformers' import type Post from '#models/post' import UserTransformer from '#transformers/user_transformer' export default class PostTransformer extends BaseTransformer { toObject() { return { ...this.pick(this.resource, ['id', 'title', 'url', 'summary', 'createdAt']), author: UserTransformer.transform(this.resource.user), } } } ``` We're using `this.pick()` to select specific fields from the Post model, and transforming the related `user` with `UserTransformer`. The starter kit already includes `UserTransformer`, which serializes user data (id, fullName, email). ::: :::step{title="Creating the controller"} Now create a controller to handle post-related requests. ```bash node ace make:controller posts ``` This creates `app/controllers/posts_controller.ts`. Add a method to list all posts: ```ts title="app/controllers/posts_controller.ts" import Post from '#models/post' import type { HttpContext } from '@adonisjs/core/http' import PostTransformer from '#transformers/post_transformer' export default class PostsController { async index({ inertia }: HttpContext) { const posts = await Post.query() .preload('user') .orderBy('createdAt', 'desc') return inertia.render('posts/index', { posts: PostTransformer.transform(posts), }) } } ``` A few things to note here: - We're preloading the `user` relationship so we can display the author's name without extra queries - We're ordering posts by creation date with newest first - We're using `PostTransformer.transform()` to serialize the posts ::: :::step{title="Defining the route"} Open your routes file and register a route. ```ts title="start/routes.ts" import router from '@adonisjs/core/services/router' import { controllers } from '#generated/controllers' router.on('/').renderInertia('home').as('home') // [!code ++] router.get('/posts', [controllers.Posts, 'index']) ``` The route connects the `/posts` URL to your controller's `index` method. When someone visits `/posts`, AdonisJS calls `PostsController.index()` and Inertia renders the React component with the posts data. :::note The `#generated/controllers` import is automatically generated by AdonisJS and provides type-safe references to your controllers. The development server watches for new controllers and regenerates this file automatically — this is why the dev server must be running when you create new controllers. For more details on how this works, see the [Controllers guide](../../../guides/basics/controllers.md#the-barrel-file). ::: ::: :::step{title="Creating the React component"} Time to create the React component that will display the posts. ```bash node ace make:page posts/index ``` This creates `inertia/pages/posts/index.tsx`. Open it and add the following code: ```tsx title="inertia/pages/posts/index.tsx" import { InertiaProps } from '~/types' import { Data } from '@generated/data' type PageProps = InertiaProps<{ posts: Data.Post[] }> export default function PostsIndex(props: PageProps) { const { posts } = props return (

Posts

{posts.map((post) => (

{post.title}

By {post.author.fullName}
. .
))}
) } ``` Let's break down what's happening: - **TypeScript props**: We're using `InertiaProps` combined with the generated `Data.Post` type to get full type safety. The `Data.Post` type is automatically generated from our `PostTransformer`. - **Mapping posts**: We loop through the posts array and display each post's title, author, and URL. - **Type safety**: Your editor will autocomplete `post.title`, `post.author.fullName`, etc., and TypeScript will catch any typos. ::: :::: Visit [`/posts`](http://localhost:3333/posts) and you should see a list of all your posts! ## Displaying a single post Now let's add the ability to view an individual post with its details. We'll implement the controller method, register the route with a dynamic parameter, and create the React component. ::::steps :::step{title="Implementing the controller method"} Add the `show` method to your controller: ```ts title="app/controllers/posts_controller.ts" import Post from '#models/post' import type { HttpContext } from '@adonisjs/core/http' import PostTransformer from '#transformers/post_transformer' export default class PostsController { async index({ inertia }: HttpContext) { const posts = await Post.query() .preload('user') .orderBy('createdAt', 'desc') return inertia.render('posts/index', { posts: PostTransformer.transform(posts), }) } // [!code ++:10] async show({ inertia, params }: HttpContext) { const post = await Post.query() .where('id', params.id) .preload('user') .firstOrFail() return inertia.render('posts/show', { post: PostTransformer.transform(post), }) } } ``` We're using `firstOrFail()` which will automatically throw a 404 error if no post exists with that ID. The pattern is the same as `index`: fetch data, transform it, and pass it as props. ::: :::step{title="Registering the route"} Register the route for this controller method: ```ts title="start/routes.ts" import router from '@adonisjs/core/services/router' import { controllers } from '#generated/controllers' router.get('/posts', [controllers.Posts, 'index']) // [!code ++] router.get('/posts/:id', [controllers.Posts, 'show']) ``` The `:id` part is a route parameter. When someone visits `/posts/5`, AdonisJS captures that `5` and makes it available in your controller as `params.id`. ::: :::step{title="Creating the React component"} Create the component for displaying a single post: ```bash node ace make:page posts/show ``` This creates `inertia/pages/posts/show.tsx`. Open it and add the following code: ```tsx title="inertia/pages/posts/show.tsx" import { InertiaProps } from '~/types' import { Data } from '@generated/data' type PageProps = InertiaProps<{ post: Data.Post }> export default function PostsShow(props: PageProps) { const { post } = props return (

{post.title}

By {post.author.fullName}
.
{post.summary}
) } ``` Again, we're using `InertiaProps` with the generated `Data.Post` type to get automatic type safety for the `post` prop. Try clicking on a post from your [list page](http://localhost:3333/posts). You should now see the full post with its title, author, and content. ::: :::: ## Using client-side navigation Right now, clicking between pages causes full page reloads. Inertia provides a `Link` component that enables client-side navigation — clicking a link fetches only the new page data via AJAX and swaps the component, making your app feel instant. Let's update the posts list to use the `Link` component: ```tsx title="inertia/pages/posts/index.tsx" import { InertiaProps } from '~/types' import { Data } from '@generated/data' // [!code ++] import { Link } from '@adonisjs/inertia/react' type PageProps = InertiaProps<{ posts: Data.Post[] }> export default function PostsIndex(props: PageProps) { const { posts } = props return (

Posts

{posts.map((post) => (

{post.title}

By {post.author.fullName}
. .
// [!code --] View comments // [!code ++] View comments
))}
) } ``` The `Link` component accepts two important props: - **`route`**: The name of the route to navigate to. In this case, `posts.show` refers to our `PostsController.show` method. - **`routeParams`**: An object containing parameters for the route. Here, we're passing the post's ID to fill in the `:id` parameter. ### About named routes You might be wondering where `posts.show` comes from. When you define a route with a controller, AdonisJS automatically generates a route name based on the controller and method names. For example, `[controllers.Posts, 'show']` automatically gets the name `posts.show`. This is much better than hardcoding URLs like `href={/posts/${post.id}}` because: - **Maintainability**: If you change the URL pattern from `/posts/:id` to `/showcase/:id` in your routes file, all your links automatically work with the new pattern. No find-and-replace needed. - **Type safety**: The `route` prop expects valid route names, so TypeScript will catch typos. - **Centralized routing**: All URL patterns are defined in one place (your routes file), making your application easier to understand and modify. Now when you click "View comments", Inertia handles the navigation without a full page reload. The browser's back button still works, and the URL updates properly — but it feels much faster. ## Adding comments to the post view Finally, let's display the comments for each post. First, we need to create a transformer for comments, update the Post transformer to include them, preload them in the controller, and finally display them in the React component. ::::steps :::step{title="Create the Comment transformer"} Create a transformer for comments: ```bash node ace make:transformer comment ``` Open it and define what data to serialize: ```ts title="app/transformers/comment_transformer.ts" import { BaseTransformer } from '@adonisjs/core/transformers' import type Comment from '#models/comment' import UserTransformer from '#transformers/user_transformer' export default class CommentTransformer extends BaseTransformer { toObject() { return { ...this.pick(this.resource, ['id', 'content', 'createdAt']), author: UserTransformer.transform(this.resource.user), } } } ``` ::: :::step{title="Update the Post transformer"} Now update `PostTransformer` to include comments: ```ts title="app/transformers/post_transformer.ts" import { BaseTransformer } from '@adonisjs/core/transformers' import type Post from '#models/post' import UserTransformer from '#transformers/user_transformer' // [!code ++] import CommentTransformer from '#transformers/comment_transformer' export default class PostTransformer extends BaseTransformer { toObject() { return { ...this.pick(this.resource, ['id', 'title', 'url', 'summary', 'createdAt']), author: UserTransformer.transform(this.resource.user), // [!code ++] comments: CommentTransformer.transform(this.whenLoaded(this.resource.comments))?.depth(2), } } } ``` We're using `this.whenLoaded()` to guard against undefined relationships. This means the `comments` field will only be included if we preloaded the relationship in the controller. ::: :::step{title="Preload comments in the controller"} Update the `show` method to preload comments and their authors: ```ts title="app/controllers/posts_controller.ts" import Post from '#models/post' import type { HttpContext } from '@adonisjs/core/http' import PostTransformer from '#transformers/post_transformer' export default class PostsController { async index({ inertia }: HttpContext) { const posts = await Post.query() .preload('user') .orderBy('createdAt', 'desc') return inertia.render('posts/index', { posts: PostTransformer.transform(posts), }) } async show({ inertia, params }: HttpContext) { const post = await Post.query() .where('id', params.id) .preload('user') // [!code ++:3] .preload('comments', (query) => { query.preload('user').orderBy('createdAt', 'asc') }) .firstOrFail() return inertia.render('posts/show', { post: PostTransformer.transform(post), }) } } ``` We're preloading comments along with each comment's user (the author), and ordering them by creation date with oldest first. ::: :::step{title="Display comments in the React component"} Update the component to display the comments: ```tsx title="inertia/pages/posts/show.tsx" import { InertiaProps } from '~/types' import { Data } from '@generated/data' type PageProps = InertiaProps<{ post: Data.Post }> export default function PostsShow(props: PageProps) { const { post } = props return (

{post.title}

By {post.author.fullName}
.
{post.summary}
// [!code ++:21]

Comments

{post.comments && post.comments.length > 0 ? ( post.comments.map((comment) => (

{comment.content}

By {comment.author.fullName} on{' '} {comment.createdAt && new Date(comment.createdAt).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric', })}
)) ) : (

No comments yet.

)}
) } ``` Notice how TypeScript knows that `post.comments` exists and what shape each comment has. This is all thanks to the generated `Data.Post` type from our transformers. ::: :::: Refresh your post detail page and you'll now see all the comments listed below the post content! ## What you've built You've just completed the full data flow in an AdonisJS + Inertia app: - **Routes** that map URLs to controller actions - **Controllers** that fetch data from your models - **Transformers** that serialize models into type-safe JSON for your frontend - **React components** that receive and display data with full TypeScript support - **Relationships** that let you eager load related data efficiently - **Client-side navigation** with Inertia's `Link` component for a SPA-like experience --- :variantSelector{} # Forms and Validation In this chapter, you'll first add the ability for authenticated users to create new posts. Then, you'll apply the same pattern to let users leave comments on existing posts. Along the way, you'll be introduced to AdonisJS's validation layer and learn how to organize your code using separate controllers for different resources. :::note This tutorial covers basic form handling and validation. For advanced topics like custom validation rules, conditional validation, error message customization, and file uploads, see the [Validation guide](../../../guides/basics/validation.md) and [VineJS documentation](https://vinejs.dev). ::: ## Overview So far in the DevShow tutorial, you've built an application that displays posts from your database. But what about creating new posts? That's where forms come in. Handling forms involves three main steps: 1. Displaying a form to collect user input. 2. Validating that input on the server to ensure it meets your requirements. 3. Finally saving the validated data to your database. AdonisJS provides Edge form components that render standard HTML form elements with automatic CSRF protection, and [VineJS](https://vinejs.dev/docs/introduction) for defining validation rules. ## Adding post creation Let's start by adding the ability for users to create new posts. We'll need a controller method to display the form, routes to wire everything up, and a template for the form itself. ::::steps :::step{title="Add controller methods"} First, let's add a `create` method to your `PostsController` that will render the form for creating a new post. We'll also stub out a `store` method that we'll implement later to handle the form submission. ```ts title="app/controllers/posts_controller.ts" import type { HttpContext } from '@adonisjs/core/http' import Post from '#models/post' export default class PostsController { // ... existing methods (index, show) // [!code ++:13] /** * Display the form for creating a new post */ async create({ view }: HttpContext) { return view.render('posts/create') } /** * Handle the form submission for creating a new post */ async store({}: HttpContext) { // We'll implement this later } } ``` ::: :::step{title="Register the routes"} Now let's wire up the routes. We need two: one to display the form and another to handle submissions. Both should only be accessible to logged-in users. :::warning The `/posts/create` route must be defined before the `/posts/:id` route. ::: ```ts title="start/routes.ts" import router from '@adonisjs/core/services/router' import { middleware } from '#start/kernel' import { controllers } from '#generated/controllers' router.get('/posts', [controllers.Posts, 'index']) // [!code ++:2] router.get('/posts/create', [controllers.Posts, 'create']).use(middleware.auth()) router.post('/posts', [controllers.Posts, 'store']).use(middleware.auth()) router.get('/posts/:id', [controllers.Posts, 'show']) ``` The `auth()` middleware ensures only logged-in users can access these routes. Unauthenticated visitors will be redirected to the login page. ::: :::step{title="Create the form template"} Create the template for the form using the Ace CLI. ```bash node ace make:view posts/create ``` This creates `resources/views/posts/create.edge`. Open it and add the following form. ```edge title="resources/views/posts/create.edge" @layout()

Share your creation

Share the URL and a short summary of your creation

@form({ route: 'posts.store', method: 'POST' })
@field.root({ name: 'title' }) @!field.label({ text: 'Post title' }) @!input.control({ placeholder: 'Title of your creation' }) @!field.error() @end
@field.root({ name: 'url' }) @!field.label({ text: 'URL' }) @!input.control({ type: 'url', placeholder: 'https://example.com/my-creation' }) @!field.error() @end
@field.root({ name: 'summary' }) @!field.label({ text: 'Short summary' }) @!textarea.control({ rows: 4, placeholder: 'Briefly describe what you are sharing' }) @!field.error() @end
@!button({ text: 'Publish', type: 'submit' })
@end
@end ``` These Edge form components are part of the starter kit. They render standard HTML elements with helpful features like automatic CSRF protection (via `@form`) and validation error display (via `@!field.error()`). ::: :::step{title="Create a validator"} Before handling form submissions, we need to define validation rules. AdonisJS uses [VineJS for validation](https://vinejs.dev), a schema-based validation library that lets you define rules for your data. Create a validator using the Ace CLI. ```bash node ace make:validator post ``` This creates `app/validators/post.ts`. Add a `createPostValidator` to validate post creation. ```ts title="app/validators/post.ts" import vine from '@vinejs/vine' /** * Validates the post's creation form */ export const createPostValidator = vine.create({ title: vine.string().minLength(3).maxLength(255), url: vine.string().url(), summary: vine.string().minLength(80).maxLength(500), }) ``` The `vine.create()` method creates a pre-compiled validator from a schema. Inside, we define each field with its type and rules. - The `title` field must be string between 3-255 characters. - The `url` field must be a string and formatted as a URL. - The `summary` field must be between 80-500 characters. ::: :::step{title="Implement the store method"} Now let's implement the `store` method to validate the data, create the post, and redirect the user. ```ts title="app/controllers/posts_controller.ts" import type { HttpContext } from '@adonisjs/core/http' import Post from '#models/post' // [!code ++:1] import { createPostValidator } from '#validators/post' export default class PostsController { // ... existing methods // [!code ++:9] async store({ request, auth, response }: HttpContext) { const payload = await request.validateUsing(createPostValidator) await Post.create({ ...payload, userId: auth.user!.id, }) return response.redirect().toRoute('posts.index') } } ``` - When the form is submitted, `request.validateUsing()` validates the data. - If validation fails, the user is automatically redirected back with errors that appear next to the relevant fields. - If validation succeeds, we create the post and associate it with the logged-in user using `auth.user.id` (available via the HTTP context), then redirect to the posts index. Now visit [`/posts/create`](http://localhost:3333/posts/create), fill out the form, and submit it. Your new post should appear on the posts page! Try submitting invalid data (like a short summary or invalid URL) to see the validation errors in action. ::: :::: ## Adding comments to posts Now that you can create posts, let's add the ability for users to leave comments. We'll create a separate controller for comments. Having one controller per resource is the recommended approach in AdonisJS. ::::steps :::step{title="Create the comment validator"} Let's start by defining validation rules for comments. ```bash node ace make:validator comment ``` Since comments only have a content field, the validation is simple. ```ts title="app/validators/comment.ts" import vine from '@vinejs/vine' /** * Validates the comment's creation form */ export const createCommentValidator = vine.create({ content: vine.string().trim().minLength(1), }) ``` ::: :::step{title="Create the CommentsController"} Generate a new controller using the Ace CLI. ```bash node ace make:controller comments ``` This creates `app/controllers/comments_controller.ts`. Add a `store` method to handle comment submissions. ```ts title="app/controllers/comments_controller.ts" import type { HttpContext } from '@adonisjs/core/http' import Comment from '#models/comment' import { createCommentValidator } from '#validators/comment' export default class CommentsController { /** * Handle the form submission for creating a new comment */ async store({ request, auth, params, response }: HttpContext) { // Validate the comment content const payload = await request.validateUsing(createCommentValidator) // Create the comment and associate it with the post and user await Comment.create({ ...payload, postId: params.id, userId: auth.user!.id, }) // Redirect back to the post page return response.redirect().back() } } ``` We're using `params.id` to get the post ID from the route parameter and use it to associate the comment with the post via `postId`. The `response.redirect().back()` sends the user back to the post page. ::: :::step{title="Register the comment route"} Add a route for creating comments, also protected by the auth middleware. ```ts title="start/routes.ts" import router from '@adonisjs/core/services/router' import { middleware } from '#start/kernel' import { controllers } from '#generated/controllers' router.on('/').render('pages/home').as('home') router.get('/posts', [controllers.Posts, 'index']) router.get('/posts/create', [controllers.Posts, 'create']).use(middleware.auth()) router.post('/posts', [controllers.Posts, 'store']).use(middleware.auth()) router.get('/posts/:id', [controllers.Posts, 'show']) // [!code ++:1] router.post('/posts/:id/comments', [controllers.Comments, 'store']).use(middleware.auth()) ``` ::: :::step{title="Add the comment form"} Open your `resources/views/posts/show.edge` template and add the comment form. ```edge title="resources/views/posts/show.edge" @layout() {{-- ... existing post display code ... --}}

Comments

// [!code ++:14]
@form({ route: 'comments.store', routeParams: post, method: 'POST' })
@field.root({ name: 'content' }) @!textarea.control({ rows: 3, placeholder: 'Share your thoughts...' }) @!field.error() @end
@!button({ text: 'Post comment', type: 'submit' })
@end
{{-- ... existing comments list ... --}}
@end ``` The `routeParams: post` passes the post object to the route helper, which extracts `post.id` to generate the correct URL like `/posts/1/comments`. Now visit any post page while logged in and try leaving a comment. After submitting, you'll be redirected back to see your comment in the list. ::: :::: ## What you learned You've now added full form handling and validation to your DevShow application. Here's what you accomplished: - Created forms using Edge form components (`@form`, `@field.root`, `@input.control`, etc.) - Defined validation rules using VineJS validators - Validated form submissions in your controllers using `request.validateUsing()` - Protected routes with the `auth()` middleware to ensure only logged-in users can create content - Associated posts and comments with users using `auth.user!.id` - Organized your code by creating separate controllers for different resources (PostsController and CommentsController) - Handled form errors automatically with the `@!field.error()` component --- :variantSelector{} # Forms and Validation In this chapter, you'll first add the ability for authenticated users to create new posts. Then, you'll apply the same pattern to let users leave comments on existing posts. Along the way, you'll be introduced to AdonisJS's validation layer and learn how to organize your code using separate controllers for different resources. :::note This tutorial covers basic form handling and validation. For advanced topics like custom validation rules, conditional validation, error message customization, and file uploads, see the [Validation guide](../../../guides/basics/validation.md) and [VineJS documentation](https://vinejs.dev). ::: ## Overview So far in the DevShow tutorial, you've built an application that displays posts from your database. But what about creating new posts? That's where forms come in. Handling forms involves three main steps: 1. Displaying a form to collect user input. 2. Validating that input on the server to ensure it meets your requirements. 3. Finally saving the validated data to your database. AdonisJS provides [VineJS](https://vinejs.dev/docs/introduction) for defining validation rules, and Inertia's `Form` component handles form submissions with automatic error handling. ## Adding post creation Let's start by adding the ability for users to create new posts. We'll need a controller method to display the form, routes to wire everything up, and a React component for the form itself. ::::steps :::step{title="Add controller methods"} First, let's add a `create` method to your `PostsController` that will render the form for creating a new post. We'll also stub out a `store` method that we'll implement later to handle the form submission. ```ts title="app/controllers/posts_controller.ts" import type { HttpContext } from '@adonisjs/core/http' import Post from '#models/post' import PostTransformer from '#transformers/post_transformer' export default class PostsController { // ... existing methods (index, show) // [!code ++:13] /** * Display the form for creating a new post */ async create({ inertia }: HttpContext) { return inertia.render('posts/create', {}) } /** * Handle the form submission for creating a new post */ async store({}: HttpContext) { // We'll implement this later } } ``` ::: :::step{title="Register the routes"} Now let's wire up the routes. We need two: one to display the form and another to handle submissions. Both should only be accessible to logged-in users. :::warning The `/posts/create` route must be defined before the `/posts/:id` route. ::: ```ts title="start/routes.ts" import router from '@adonisjs/core/services/router' import { middleware } from '#start/kernel' import { controllers } from '#generated/controllers' router.get('/posts', [controllers.Posts, 'index']) // [!code ++:2] router.get('/posts/create', [controllers.Posts, 'create']).use(middleware.auth()) router.post('/posts', [controllers.Posts, 'store']).use(middleware.auth()) router.get('/posts/:id', [controllers.Posts, 'show']) ``` The `auth()` middleware ensures only logged-in users can access these routes. Unauthenticated visitors will be redirected to the login page. ::: :::step{title="Create the form component"} Create the React component for the form using the Ace CLI. ```bash node ace make:page posts/create ``` This creates `inertia/pages/posts/create.tsx`. Open it and add the following form: ```tsx title="inertia/pages/posts/create.tsx" import { Form } from '@adonisjs/inertia/react' export default function PostsCreate() { return (

Share your creation

Share the URL and a short summary of your creation

{({ errors }) => ( <>
{errors.title &&
{errors.title}
}
{errors.url &&
{errors.url}
}
``` :::note When using AdonisJS validators, validation errors and form data are automatically flashed on validation failure. You don't need to manually flash them. ::: ### Re-flashing messages Sometimes you need to keep flash messages for an additional request. Use `reflash()` to preserve all messages from the previous request for one more cycle. ```ts title="app/middleware/check_subscription_middleware.ts" export default class CheckSubscriptionMiddleware { async handle({ auth, session, response }: HttpContext, next: NextFn) { const user = auth.getUserOrFail() if (!user.hasActiveSubscription) { /** * Keep all flash messages for one more request. */ session.reflash() session.flash('error', 'Please subscribe to continue') return response.redirect('/subscribe') } await next() } } ``` You can also selectively re-flash only specific message types. ```ts // Re-flash only error messages session.reflashOnly(['error']) // Re-flash all messages except success session.reflashExcept(['success']) ``` ## Intended URL The intended URL feature allows you to store a URL the user should be redirected to after completing an intermediate step. For example, you can store the current page URL before redirecting the user to a login page, and after login, redirect them back to the page they were on. URLs are validated before storage to prevent open redirect attacks. Invalid or unsafe URLs (like protocol-relative `//evil.com`) are silently ignored. ```ts title="app/controllers/session_controller.ts" import type { HttpContext } from '@adonisjs/core/http' export default class SessionController { async show({ request, session, view }: HttpContext) { /** * If the login page is accessed with an ?intended query param, * store the intended URL in the session for use after login */ const intended = request.input('intended') if (intended) { session.setIntendedUrl(intended) } return view.render('auth/login') } async store({ request, auth, response }: HttpContext) { const user = await User.verifyCredentials( request.input('email'), request.input('password') ) await auth.use('web').login(user) /** * Redirect to the intended URL if one was stored, * otherwise fall back to dashboard */ return response.redirect().toIntendedRoute('dashboard') } } ``` You can also read, consume, or clear the intended URL directly. ```ts title="start/routes.ts" import router from '@adonisjs/core/services/router' router.get('/example', ({ session }) => { /** * Read without consuming */ const url = session.getIntendedUrl() /** * Read and remove in one step */ const consumed = session.pullIntendedUrl() /** * Remove without reading */ session.clearIntendedUrl() }) ``` See also: [Redirecting to the intended URL after login](../auth/session_guard.md#redirecting-to-the-intended-url) ## Session regeneration Session regeneration creates a new session ID while preserving all existing session data. This is a critical security measure to prevent [session fixation attacks](https://owasp.org/www-community/attacks/Session_fixation), where an attacker tricks a user into using a session ID controlled by the attacker. When a user logs in, their authentication state changes from unauthenticated to authenticated. If the same session ID is kept, an attacker who knew the session ID before login could hijack the authenticated session. Regenerating the session ID after login prevents this attack. AdonisJS automatically handles session regeneration when using the official Auth package. However, if you're implementing custom authentication, you must manually call `session.regenerate()` after successful login. ```ts title="app/controllers/auth_controller.ts" export default class AuthController { async login({ request, session, response }: HttpContext) { const { email, password } = request.only(['email', 'password']) // Verify credentials... const user = await User.verifyCredentials(email, password) /** * Generate a new session ID while preserving data. * This prevents session fixation attacks. */ await session.regenerate() // Store user info in the new session session.put('user_id', user.id) return response.redirect('/dashboard') } } ``` ## Creating custom session stores If none of the built-in drivers meet your needs, you can create a custom session store by implementing the `SessionStoreContract` interface. This is useful for integrating databases like MongoDB or custom storage solutions. ### Implementing the store contract Create a class that implements the four required methods. ```ts title="app/session_stores/mongodb_store.ts" import { SessionData, SessionStoreFactory, SessionStoreContract, } from '@adonisjs/session/types' /** * The config you want to accept */ export type MongoDBConfig = { collection: string; database: string } /** * The MongoDbStore class handles the actual storage operations. * It implements the four required methods: read, write, destroy, and touch. */ export class MongoDbStore implements SessionStoreContract { constructor(protected config: MongoDBConfig) {} /** * Read session data for a given session ID. * Return null if the session doesn't exist. */ async read(sessionId: string): Promise { // Query your storage and return session data } /** * Write session data for a given session ID. */ async write( sessionId: string, data: SessionData, expiresAt: Date ): Promise { // Save session data to your storage } /** * Delete session data for a given session ID. */ async destroy(sessionId: string): Promise { // Remove session from your storage } /** * Update the session's expiration time without changing data. */ async touch(sessionId: string, expiresAt: Date): Promise { // Update expiration timestamp in your storage } } /** * The factory function accepts configuration and returns a driver * function. The driver function creates a new instance of the store * class when called by the session manager. */ export function mongoDbStore(config: MongoDbConfig): SessionStoreFactory { return (ctx, sessionConfig) => { return new MongoDBStore(config) } } ``` ### Registering your custom store Register your custom store in `config/session.ts` using the factory function. ```ts title="config/session.ts" import { defineConfig } from '@adonisjs/session' import { mongoDbStore } from '#session_stores/mongodb_store' export default defineConfig({ store: env.get('SESSION_DRIVER'), stores: { mongodb: mongoDbStore({ collection: 'sessions', database: 'myapp' }) } }) ``` Set your environment variable to use the custom store. ```dotenv title=".env" SESSION_DRIVER=mongodb ``` For complete implementation examples, see the [built-in session stores on GitHub](https://github.com/adonisjs/session/tree/8.x/src/stores). --- # URL Builder This guide covers URL generation in AdonisJS applications. You will learn how to: - Generate URLs for named routes with type-safe autocompletion - Pass route parameters using arrays or objects - Add query strings to generated URLs - Create signed URLs with cryptographic signatures for secure links - Verify signed URLs to prevent tampering - Integrate URL generation into frontend applications using Inertia ## Overview The URL builder provides a type-safe API for generating URLs from named routes. Instead of hard-coding URLs throughout your application in templates, frontend components, API responses, or redirects, you reference routes by name. This ensures that when you change a route's path, you don't need to hunt down and update every URL reference across your codebase. Once a route is named, you can generate URLs for it using the `urlFor` helper in templates, the `response.redirect().toRoute()` method for redirects, or by importing the `urlFor` function from the URL builder service for other contexts. The URL builder is type-safe, meaning your IDE will provide autocompletion for route names and TypeScript will catch errors if you reference a non-existent route. This eliminates an entire class of bugs where URLs might break silently after refactoring routes. ## Defining named routes Every route using a controller is automatically assigned a name based on the controller and method name. The naming convention follows the pattern `controller.method` (explained in detail in the [routing guide](./routing.md#route-identifiers)). ```ts title="start/routes.ts" import router from '@adonisjs/core/services/router' import { controllers } from '#generated/controllers' // Automatically named as 'posts.show' router.get('/posts/:id', [controllers.posts, 'show']) // Automatically named as 'posts.index' router.get('/posts', [controllers.posts, 'index']) ``` For routes without controllers, you must explicitly assign a name using the `.as()` method. ```ts title="start/routes.ts" router.get('/about', async () => { return 'About page' }).as('about') ``` You can view all named routes in your application using the following Ace command. ```bash node ace list:routes ``` ## Generating URLs in templates Edge templates have access to the `urlFor` helper by default. This helper generates URLs for named routes and accepts route parameters as either an array or an object. ```edge title="resources/views/posts/index.edge" View post ``` When using the Hypermedia starter kit, you can also use the `@link` component, which accepts the route and parameters as component props. ```edge title="resources/views/posts/index.edge" @link({ route: 'posts.show', routeParams: { id: post.id } }) View post @end ``` ## Generating URLs during redirects When redirecting users to a different page, use the `response.redirect().toRoute()` method instead of hard-coding URLs. You can only redirect to `GET` routes. ```ts title="app/controllers/posts_controller.ts" import type { HttpContext } from '@adonisjs/core/http' import Post from '#models/post' export default class PostsController { async store({ request, response }: HttpContext) { const post = await Post.create(request.all()) return response .redirect() .toRoute('posts.show', { id: post.id }) } } ``` ## Generating URLs in other contexts For contexts outside of templates and HTTP responses, such as background jobs, email notifications, or service classes, import the `urlFor` function from the URL builder service. ```ts title="app/services/notification_service.ts" import { urlFor } from '@adonisjs/core/services/url_builder' export default class NotificationService { async sendPostNotification(post: Post) { const postUrl = urlFor('posts.show', { id: post.id }) await mail.send({ subject: 'New post published', html: `View post` }) } } ``` ## Passing route parameters Route parameters can be passed as either an array (positional matching) or an object (named matching). Choose the approach that makes your code more readable. **Array (positional parameters):** Parameters are matched by position to the route pattern. ```ts // Route: /posts/:id urlFor('posts.show', [1]) // Output: /posts/1 // Route: /users/:userId/posts/:postId urlFor('users.posts.show', [5, 10]) // Output: /users/5/posts/10 ``` **Object (named parameters):** Parameters are matched by name to the route pattern. ```ts // Route: /posts/:id urlFor('posts.show', { id: 1 }) // Output: /posts/1 // Route: /users/:userId/posts/:postId urlFor('users.posts.show', { userId: 5, postId: 10 }) // Output: /users/5/posts/10 ``` ## Adding query strings Query strings can be added to generated URLs by passing a third options parameter with a `qs` property. The query string object can contain nested values, which are automatically serialized into the proper format. ```ts title="app/controllers/posts_controller.ts" import { urlFor } from '@adonisjs/core/services/url_builder' const url = urlFor('posts.index', [], { qs: { filters: { title: 'typescript', }, order: { direction: 'asc', column: 'id' }, } }) // Output: /posts?filters[title]=typescript&order[direction]=asc&order[column]=id ``` The same `qs` option works in templates and redirects. ```edge title="resources/views/partials/pagination.edge" Next page ``` ```ts title="app/controllers/posts_controller.ts" response.redirect().toRoute('posts.index', [], { qs: { page: 2, sort: 'title' } }) ``` ## Signed URLs Signed URLs include a cryptographic signature that prevents tampering. If someone modifies the URL, the signature becomes invalid and the request can be rejected. This is useful for scenarios where URLs are publicly accessible but need protection against manipulation, such as newsletter unsubscribe links or password reset tokens. ### Creating signed URLs Signed URLs are created using the `signedUrlFor` helper exported from the URL builder service. The API is identical to `urlFor`, but the generated URL includes a signature. ```ts title="app/mails/newsletter_mail.ts" import User from '#models/user' import { appUrl } from '#config/app' import { BaseMail } from '@adonisjs/mail' // [!code highlight] import { signedUrlFor } from '@adonisjs/core/services/url_builder' export default class NewsletterMail extends BaseMail { subject = 'Weekly Newsletter' constructor(protected user: User) { super() } prepare() { // [!code highlight:8] const unsubscribeUrl = signedUrlFor( 'newsletter.unsubscribe', { email: this.user.email }, { expiresIn: '30 days', prefixUrl: appUrl, } ) this.message.htmlView('emails/newsletter', { user: this.user, unsubscribeUrl }) } } ``` The `expiresIn` option sets when the signed URL expires. After expiration, the signature is no longer valid. The `prefixUrl` option is required when the URL will be shared externally, such as in emails or external notifications, to ensure the URL includes the full domain. For internal app navigation, relative URLs without the domain are sufficient. The generated signed URL includes a signature query parameter appended to the URL. ```text https://example.com/newsletter/unsubscribe?email=user@example.com&signature=eyJtZXNzYWdlIjoiL25ld3NsZXR0ZXIvdW5zdWJzY3JpYmU_ZW1haWw9dXNlckBleGFtcGxlLmNvbSIsInB1cnBvc2UiOiJzaWduZWRfdXJsIn0.1234567890abcdef ``` :::note Signed URLs can only be created in backend code, not in frontend applications. This is because they rely on the encryption module, which uses a secret key. Exposing this key to the frontend would compromise security. ::: ### Verifying signed URLs The route for which the signed URL was generated can verify the signature using the `request.hasValidSignature()` method during an HTTP request. This method checks both the signature and expiration. ```ts title="app/controllers/newsletter_controller.ts" import type { HttpContext } from '@adonisjs/core/http' export default class NewsletterController { async unsubscribe({ request, response }: HttpContext) { if (!request.hasValidSignature()) { return response.badRequest('Invalid or expired unsubscribe link') } const email = request.qs().email // Process unsubscribe request } } ``` ## Frontend integration The URL builder service is available only within the backend application since that's where routes are defined. However, applications using Inertia or a separate frontend inside a monorepo can generate a similar URL builder for the frontend codebase. ### Why separate frontend and backend URL builders? AdonisJS enforces a clear boundary between frontend and backend codebases to prevent issues like leaking sensitive information to the client. Routes on the backend contain details about controller mappings and internal application structure. Making all this information available on the frontend would not only leak unnecessary information but also significantly increase your bundle size. Additionally, from a runtime perspective, you cannot share an object between two different runtimes (Node.js and the browser). Creating the illusion of a shared URL builder is not something we support or believe in. ### Using the URL builder in Inertia apps The Inertia React and Vue starter kits come with the URL builder pre-configured. The URL builder (along with the API client) is generated using [Tuyau](../frontend/api_client.md) and written to the `.adonisjs/client` directory. Import and use the URL builder in your frontend components with an identical API to the backend version. ```tsx title="inertia/pages/posts/index.tsx" import { urlFor } from '~/client' export default function PostsIndex({ posts }) { return (
{posts.map(post => ( {post.title} ))}
) } ``` The usage API is identical to the backend URL builder service, supporting both array and object parameters, as well as query strings. ```ts // Using positional parameters urlFor('posts.show', [post.id]) // Using named parameters urlFor('posts.show', { id: post.id }) // Adding query strings urlFor('posts.index', [], { qs: { page: 2, sort: 'title' } }) ``` ### Excluding routes from frontend bundle You can configure which routes are available in the frontend URL builder to reduce bundle size and prevent exposing internal routes. The URL builder is generated using an Assembler hook named `generateRegistry` registered in your `adonisrc.ts` file. Define routes to exclude using the `exclude` option. ```ts title="adonisrc.ts" import { defineConfig } from '@adonisjs/core/app' import { generateRegistry } from '@adonisjs/assembler/hooks' export default defineConfig({ init: [ generateRegistry({ exclude: ['admin.*'], }) ], }) ``` The exclude pattern can use one of the following approaches, tested against the route name: :::option{name="Wildcard pattern"} Exclude all routes starting with the prefix before the wildcard. ```ts title="adonisrc.ts" generateRegistry({ exclude: ['admin.*', 'api.internal.*'], }) ``` ::: :::option{name="Regular expression"} Use a custom regular expression to exclude matching routes. ```ts title="adonisrc.ts" generateRegistry({ exclude: [/^admin\./, /^api\.internal\./], }) ``` ::: :::option{name="Custom function"} Write a custom function for advanced filtering logic. Return `false` to skip the route and `true` to include it. ```ts title="adonisrc.ts" generateRegistry({ exclude: [ (route) => { // Exclude all routes on the admin domain if (route.domain === 'admin.myapp.com') { return false } // Include all other routes return true } ], }) ``` ::: You can combine multiple patterns for complex filtering requirements. ```ts title="adonisrc.ts" generateRegistry({ exclude: [ 'admin.*', /^api\.internal\./, (route) => route.domain !== 'admin.myapp.com' ], }) ``` --- # Exception Handling This guide covers exception handling in AdonisJS applications. You will learn how to: - Use the global exception handler to convert errors into HTTP responses - Customize error handling for specific error types - Report errors to logging services - Create custom exception classes with self-contained handling logic - Configure debug mode and status pages for different environments ## Overview Exception handling in AdonisJS provides a centralized system for managing errors during HTTP requests. Instead of wrapping every route handler and middleware in try/catch blocks, you let errors bubble up naturally to a global exception handler that converts them into appropriate HTTP responses. This approach keeps your code clean while ensuring consistent error handling across your application. ### The global exception handler When you create a new AdonisJS project, the global exception handler is created in `app/exceptions/handler.ts`. It extends the base `ExceptionHandler` class and provides two primary methods: - The `handle` converts errors into HTTP responses. - The `report` logs errors or sends them to monitoring services. Here's what the default handler looks like: ```ts title="app/exceptions/handler.ts" import app from '@adonisjs/core/services/app' import { HttpContext, ExceptionHandler } from '@adonisjs/core/http' import type { StatusPageRange, StatusPageRenderer } from '@adonisjs/core/types/http' export default class HttpExceptionHandler extends ExceptionHandler { /** * Controls verbose error display with stack traces. * Automatically disabled in production to protect sensitive info. */ protected debug = !app.inProduction /** * Enables custom HTML error pages for specific status codes. * Typically enabled in production for better user experience. */ protected renderStatusPages = app.inProduction /** * Maps status codes or ranges to view templates. * Keys can be specific codes like '404' or ranges like '500..599'. */ protected statusPages: Record = { '404': (error, { view }) => { return view.render('pages/errors/not_found', { error }) }, '500..599': (error, { view }) => { return view.render('pages/errors/server_error', { error }) }, } /** * Converts errors into HTTP responses for the client. * Override to customize error response formatting. */ async handle(error: unknown, ctx: HttpContext) { return super.handle(error, ctx) } /** * Logs errors or sends them to monitoring services. * Never attempt to send HTTP responses from this method. */ async report(error: unknown, ctx: HttpContext) { return super.report(error, ctx) } } ``` The inline comments explain each property's purpose. We'll explore `debug`, `renderStatusPages`, and `statusPages` in detail later in this guide. ### How errors flow through the handler When an error occurs during an HTTP request, AdonisJS automatically catches it and forwards it to the global exception handler. Let's see this in action. ```ts title="start/routes.ts" import router from '@adonisjs/core/services/router' import { Exception } from '@adonisjs/core/exceptions' router.get('fatal', () => { /** * Throwing an exception with a 500 status code * and a custom error code for identification */ throw new Exception('Something went wrong', { status: 500, code: 'E_RUNTIME_EXCEPTION' }) }) ``` In development mode (with `debug` enabled), visiting this route displays a beautifully formatted error page powered by Youch, showing the error message, full stack trace, and request context. In production mode (with `debug` disabled), the same error returns a simple JSON or plain text response containing only the error message, without exposing your application's internal structure. ### Handling specific error types The global exception handler's `handle` method receives all unhandled errors. You can inspect the error type and provide custom handling for specific exceptions while letting others fall through to the default behavior. Here's an example of handling validation errors with a custom response format. ```ts title="app/exceptions/handler.ts" import { errors as vineJSErrors } from '@vinejs/vine' import { HttpContext, ExceptionHandler } from '@adonisjs/core/http' export default class HttpExceptionHandler extends ExceptionHandler { protected debug = !app.inProduction protected renderStatusPages = app.inProduction async handle(error: unknown, ctx: HttpContext) { /** * Check if the error is a VineJS validation error * using instanceof to safely identify the error type */ if (error instanceof vineJSErrors.E_VALIDATION_ERROR) { /** * Return validation messages directly as JSON * with a 422 Unprocessable Entity status */ ctx.response.status(422).send(error.messages) return } /** * For all other errors, delegate to the parent class * which handles the default error conversion logic */ return super.handle(error, ctx) } } ``` This pattern of checking error types using `instanceof` and providing custom handling is powerful and flexible. You can add as many conditional branches as needed for different error types in your application. Here's how you might use this custom validation error handling in a route. ```ts title="start/routes.ts" import router from '@adonisjs/core/services/router' import { createPostValidator } from '#validators/post' router.post('posts', async ({ request }) => { /** * If validation fails, VineJS throws E_VALIDATION_ERROR * which is caught by our custom handler and returns * the validation messages with a 422 status code */ await request.validateUsing(createPostValidator) }) ``` ### Debug mode and Youch The `debug` property controls whether errors are displayed using Youch, an error visualization tool that creates beautiful, interactive error pages. When debug mode is enabled, Youch displays the error message, complete stack trace, request details, and even shows the exact code where the error occurred with syntax highlighting. :::media ![](./youch_pretty_error_page.png) ::: In production, debug mode should always be disabled to prevent exposing sensitive information. When disabled, errors are converted to simple responses using content negotiation (JSON for API requests, plain text for others) containing only the error message without implementation details. The default configuration `protected debug = !app.inProduction` automatically handles this for you, enabling debug mode in development and disabling it in production. ### Status pages Status pages allow you to display custom HTML pages for specific HTTP status codes. This feature is particularly useful for user-facing applications where you want to provide a branded, helpful error experience rather than a generic error message. The `statusPages` property is a key-value map where keys are HTTP status codes or ranges, and values are callback functions that render and return HTML content. The callback receives the error object and the HTTP context, giving you full access to view rendering and error details. ::::tabs :::tab{title="Edge (Hypermedia apps)"} ```ts export default class HttpExceptionHandler extends ExceptionHandler { protected statusPages: Record = { '404': (error, { view }) => { return view.render('pages/errors/not_found', { error }) }, '500..599': (error, { view }) => { return view.render('pages/errors/server_error', { error }) }, } } ``` ::: :::tab{title="Inertia apps"} When using the Inertia starter kit, status pages render Inertia components instead of Edge templates. The callback receives the `inertia` object from the HTTP context, which you use to render a frontend page component. ```ts export default class HttpExceptionHandler extends ExceptionHandler { protected statusPages: Record = { '404': (error, { inertia }) => { return inertia.render('errors/not_found', { error }) }, '500..599': (error, { inertia }) => { return inertia.render('errors/server_error', { error }) }, } } ``` The page paths (e.g., `errors/not_found`) map to files inside `inertia/pages/` — for example, `inertia/pages/errors/not_found.tsx`. ::: :::: Status pages are only rendered when the `renderStatusPages` property is set to `true`. The default configuration enables them in production (`app.inProduction`) where custom error pages provide a better user experience, while keeping them disabled in development where detailed Youch error pages are more useful for debugging. :::note Status pages only apply to requests that accept an HTML or Inertia response. For API requests that accept JSON (e.g., requests with `Accept: application/json`), the exception handler returns a JSON error response instead. This means the API starter kit does not include `renderStatusPages` or `statusPages` in its exception handler, since all responses are JSON. ::: ## Reporting errors The `report` method logs errors or sends them to external monitoring services. Unlike `handle`, it should never send HTTP responses. These are two distinct concerns that should remain separated. The base `ExceptionHandler` class provides a default implementation of `report` that logs errors using AdonisJS's logger. You can override this method to add custom reporting logic, such as integrating with error monitoring services. ```ts title="app/exceptions/handler.ts" export default class HttpExceptionHandler extends ExceptionHandler { async report(error: unknown, ctx: HttpContext) { /** * First call the parent report method to ensure * the error is logged using the default behavior */ await super.report(error, ctx) /** * Add custom reporting logic here, such as * sending to Sentry, Bugsnag, or other services */ } } ``` ### Adding context to error reports The `context` method allows you to define additional data that should be included with every error report. This contextual information helps you understand the circumstances under which an error occurred, making debugging much easier. By default, the context includes the request ID (`x-request-id` header). You can override this method to include any additional information relevant to your application. ```ts title="app/exceptions/handler.ts" export default class HttpExceptionHandler extends ExceptionHandler { protected context(ctx: HttpContext) { return { /** * Include the unique request ID for tracking * this specific request across logs */ requestId: ctx.request.id(), /** * Add the authenticated user's ID if available * to identify which user encountered the error */ userId: ctx.auth.user?.id, /** * Include the IP address for security monitoring * and identifying patterns in errors */ ip: ctx.request.ip(), } } } ``` This context data is automatically included whenever an error is reported, giving you rich information about each error's circumstances without manually adding this data to every report call. ### Ignoring errors from reports Not all errors need to be reported. Some errors, like validation failures or unauthorized access attempts, are expected parts of normal application flow and don't require logging or monitoring. You can configure which errors to exclude from reporting using the `ignoreStatuses` and `ignoreCodes` properties. ```ts title="app/exceptions/handler.ts" export default class HttpExceptionHandler extends ExceptionHandler { /** * HTTP status codes that should not be reported. * These are typically client errors that don't indicate * problems with your application. */ protected ignoreStatuses = [400, 401, 403, 404, 422] /** * Error codes that should not be reported. * These are application-specific error codes for * expected error conditions. */ protected ignoreCodes = ['E_VALIDATION_ERROR', 'E_UNAUTHORIZED_ACCESS'] } ``` The base `ExceptionHandler` class checks these properties in its `shouldReport` method before reporting an error. If you implement custom reporting logic, you must respect this check. ```ts title="app/exceptions/handler.ts" export default class HttpExceptionHandler extends ExceptionHandler { async report(error: unknown, ctx: HttpContext) { /** * Convert the error to a standardized HTTP error * format that includes status code and error code */ const httpError = this.toHttpError(error) /** * Only report the error if it passes the shouldReport check, * which verifies it's not in ignoreStatuses or ignoreCodes */ if (this.shouldReport(httpError)) { // Your custom reporting logic here // For example: send to external monitoring service } } } ``` This approach ensures consistent error filtering across your application, preventing your logs and monitoring services from being overwhelmed with expected errors. ## Custom exceptions Custom exceptions allow you to create specialized error classes for specific error conditions in your application's business logic. A custom exception extends the base `Exception` class and can implement its own `handle` and `report` methods, encapsulating both the error condition and its handling logic in a single, self-contained class. ### Creating a custom exception You can create a custom exception using the `make:exception` command. ```sh node ace make:exception PaymentFailed ``` ```sh CREATE: app/exceptions/payment_failed_exception.ts ``` This generates a new exception class in the `app/exceptions` directory. Here's what a complete custom exception looks like with both handling and reporting logic. ```ts title="app/exceptions/payment_failed_exception.ts" import { Exception } from '@adonisjs/core/exceptions' import { HttpContext } from '@adonisjs/core/http' export default class PaymentFailedException extends Exception { /** * The HTTP status code for this exception. * Set as a static property so it can be accessed * without instantiating the exception. */ static status = 400 /** * Handle the exception by converting it to an HTTP response. * This method is called automatically when this exception * is thrown and not caught. */ handle(error: this, { response }: HttpContext) { return response .status(error.constructor.status) .send('Unable to process the payment. Please try again') } /** * Report the exception for logging and monitoring. * This method is called before handle() to record * the error occurrence. */ report(error: this, { logger, auth }: HttpContext) { logger.error( { user: auth.user }, 'Payment failed for user %s', auth.user?.id ) } } ``` When you throw this custom exception anywhere in your application, AdonisJS automatically calls its `handle` method to generate the HTTP response and its `report` method to log the error. The global exception handler is bypassed entirely for custom exceptions that implement these methods. ### When to use custom exceptions Custom exceptions are ideal when you need to throw meaningful, business-logic-specific errors throughout your application. They're particularly useful for error conditions that require specialized handling or reporting. Unlike handling errors in the global exception handler, custom exceptions encapsulate both the error condition and its handling logic, making your error handling more organized and maintainable. The global exception handler, on the other hand, is meant to change the default behavior for how exceptions are handled application-wide. It's the right place for cross-cutting concerns like formatting all API errors consistently or integrating with monitoring services. Use custom exceptions when the error is specific to your domain and requires unique handling. Use the global exception handler when you need to modify how a category of errors is processed across your entire application. ## Configuration reference The exception handler class provides several configuration options that control error handling behavior: ::::options :::option{name="debug" dataType="boolean"} When `true`, displays detailed error pages with stack traces using Youch. Should be `false` in production. Default: `!app.inProduction` ::: :::option{name="renderStatusPages" dataType="boolean"} When `true`, renders custom HTML pages for configured status codes. Default: `app.inProduction` ::: :::option{name="statusPages" dataType="Record"} Maps HTTP status codes or ranges to view rendering callbacks for custom error pages. ::: :::option{name="ignoreStatuses" dataType="number[]"} Array of HTTP status codes that should not be reported via the `report` method. ::: :::option{name="ignoreCodes" dataType="string[]"} Array of error codes that should not be reported via the `report` method. ::: :::: ## See also - [ExceptionHandler source code](https://github.com/adonisjs/http-server/blob/8.x/src/exception_handler.ts) - Complete implementation details of the base exception handler class - [Make exception command](../../reference/exceptions.md#makeexception) - CLI reference for generating custom exception classes --- # Debugging This guide covers debugging techniques for AdonisJS applications. You will learn how to: - Configure VSCode to debug your application with breakpoints - Use the Node.js inspector from the command line - View framework-level debug logs with `NODE_DEBUG` - Inspect variables in Edge templates with `@dump` and `@dd` - Enable pretty error pages during development ## Overview Debugging is an essential part of development, and AdonisJS supports multiple approaches depending on your needs. For quick checks, a simple `console.log` statement often suffices. For more complex issues, you can use VSCode's integrated debugger to set breakpoints, step through code, and inspect variables. When you need to understand what's happening inside the framework itself, debug logs provide visibility into AdonisJS internals. Edge templates have their own debugging tools with `@dump` and `@dd`, which render variable contents directly in the browser. During development, the exception handler automatically displays detailed error pages with stack traces and request information when something goes wrong. ## VSCode debugger The VSCode debugger provides the most powerful debugging experience, allowing you to set breakpoints, step through code line by line, and inspect the call stack and variables. Use this approach when debugging complex issues that can't be resolved with simple log statements. Create a `.vscode/launch.json` file in your project root with configurations for the dev server, test runner, and attach mode. ```json title=".vscode/launch.json" { "version": "0.2.0", "configurations": [ { "type": "node", "request": "launch", "name": "Dev server", "program": "${workspaceFolder}/ace.js", "args": ["serve", "--hmr"], "skipFiles": ["/**"] }, { "type": "node", "request": "launch", "name": "Tests", "program": "${workspaceFolder}/ace.js", "args": ["test", "--watch"], "skipFiles": ["/**"] }, { "type": "node", "request": "attach", "name": "Attach Program", "port": 9229, "autoAttachChildProcesses": true, "skipFiles": ["/**"] } ] } ``` The **Dev server** configuration launches your application with HMR enabled, perfect for debugging HTTP request handling, middleware, and controllers. The **Tests** configuration runs your test suite in watch mode, allowing you to debug failing tests by setting breakpoints in your test files or application code. ### Debugging Ace commands The [**Attach Program**](https://code.visualstudio.com/blogs/2018/07/12/introducing-logpoints-and-auto-attach#_autoattaching-to-node-processes) configuration uses attach mode instead of launching a specific command. This lets you debug any Ace command by starting it with the `--inspect` flag and then attaching the debugger. To debug an Ace command: 1. Open the Command Palette with `Cmd+Shift+P` (macOS) or `Ctrl+Shift+P` (Windows/Linux) 2. Search for **Debug: Select and Start Debugging** 3. Select the **Attach Program** option 4. Run your Ace command with the `--inspect` flag. ```bash node --inspect ace migration:run ``` The debugger will attach to the running process, and your breakpoints will be hit. ## Node.js inspector If you're not using VSCode or prefer a different debugging interface, you can use the Node.js inspector directly. Start your dev server with the `--inspect` flag. ```bash node ace --inspect serve --hmr ``` This starts the Node.js inspector on port 9229. You can then connect using Chrome DevTools by navigating to `chrome://inspect` in Chrome, or use any other debugger that supports the Node.js inspector protocol. ## Framework debug logs AdonisJS packages include debug logs that provide visibility into framework internals. These logs are disabled by default because they're verbose, but they're invaluable when you need to understand what's happening at the framework level. Enable debug logs by setting the `NODE_DEBUG` environment variable when starting your application. ```bash NODE_DEBUG=adonisjs:* node ace serve --hmr ``` The wildcard `*` enables logs from all AdonisJS packages. If you already know which package you're investigating, specify it directly to reduce noise. ```bash # Debug only the HTTP layer NODE_DEBUG=adonisjs:http node ace serve --hmr # Debug session handling NODE_DEBUG=adonisjs:session node ace serve --hmr # Debug the application lifecycle NODE_DEBUG=adonisjs:app node ace serve --hmr ``` Package names follow the convention `adonisjs:`, where the package name corresponds to the AdonisJS package you want to debug. ## Edge template debugging When working with Edge templates, you can inspect variables directly in the browser using `@dump` and `@dd`. These tags render a formatted representation of any value, making it easy to understand what data your templates are receiving. ### The @dump tag The `@dump` tag outputs a formatted representation of a value and continues rendering the rest of the template: ```edge title="resources/views/posts/show.edge" {{-- Inspect component props --}} @dump($props.all()) {{-- Inspect the entire template state --}} @dump(state) {{-- Inspect a specific variable --}} @dump(post) ``` ### The @dd tag The `@dd` tag (dump and die) stops template rendering immediately and displays only the dumped value. Use this when you want to focus on a specific value without the rest of the page's output: ```edge title="resources/views/posts/show.edge" @dd(post) {{-- Nothing below this line will render --}}

{{ post.title }}

``` ### Setting up the dumper The `@dump` and `@dd` tags require the dumper's frontend assets to be loaded. Add the `@stack('dumper')` directive to your layout's `` section. ```edge title="resources/views/layouts/main.edge" @stack('dumper') @!section('content') ``` Official AdonisJS starter kits include this setup by default. ### Configuring the dumper You can customize how the dumper formats output by exporting a `dumper` configuration from `config/app.ts`. ```ts title="config/app.ts" import { defineConfig as dumperConfig } from '@adonisjs/core/dumper' export const dumper = dumperConfig({ /** * Settings for console output (e.g., console.log) */ console: { depth: 10, collapse: ['DateTime', 'Date'], inspectStaticMembers: true, }, /** * Settings for HTML output (@dump and @dd) */ html: { depth: 10, inspectStaticMembers: true, }, }) ``` The following options are available for both `console` and `html` printers. ::::options :::option{name="showHidden" dataType="boolean" defaultValue="false"} Include non-enumerable properties ::: :::option{name="depth" dataType="number" defaultValue="5"} Maximum depth for nested structures (objects, arrays, maps, sets) ::: :::option{name="inspectObjectPrototype" dataType="boolean | string" defaultValue="unless-plain-object"} Include prototype properties. Set to `true` for all objects, `false` for none, or `'unless-plain-object'` for class instances only. ::: :::option{name="inspectArrayPrototype" dataType="boolean" defaultValue="false"} Include array prototype properties ::: :::option{name="inspectStaticMembers" dataType="boolean" defaultValue="false"} Include static members of classes ::: :::option{name="maxArrayLength" dataType="number" defaultValue="100"} Maximum items to display for arrays, maps, and sets ::: :::option{name="maxStringLength" dataType="number" defaultValue="1000"} Maximum characters to display for strings ::: :::option{name="collapse" dataType="string[]" defaultValue="[]"} Array of constructor names that should not be expanded further ::: :::: ## Exception handler debug mode During development, AdonisJS displays [detailed error pages](./exception_handling.md#debug-mode-and-youch) powered by Youch when an unhandled exception occurs. These pages include the full stack trace, request information, and application state, making it easy to diagnose issues. Debug mode is enabled automatically in development and disabled in production. This behavior is controlled by the exception handler configuration. --- # Static files server This guide covers serving static files in AdonisJS applications. You will learn how to: - Install and configure the static files middleware - Understand when to use static files versus compiled assets - Configure caching, ETags, and HTTP headers for optimal performance - Control access to dot files for security - Set up custom headers for specific file types - Copy static files to production builds ## Overview The static file server lets you serve files directly from the file system without creating route handlers for each file. This is essential for assets that don't need processing, like favicons, robots.txt files, user uploads, or downloadable PDFs. Without a static file server, you would need to create individual routes for every file you want to serve. This quickly becomes unmaintainable: ```ts title="start/routes.ts" // Without static middleware - tedious and error-prone router.get('/favicon.ico', async ({ response }) => { return response.download('public/favicon.ico') }) router.get('/robots.txt', async ({ response }) => { return response.download('public/robots.txt') }) router.get('/images/logo.png', async ({ response }) => { return response.download('public/images/logo.png') }) // ... potentially hundreds of routes ``` With the static middleware, all files in the `public` directory are automatically available. The middleware intercepts HTTP requests before they reach your routes. If a file matching the request path exists, it serves the file with appropriate HTTP headers for caching and performance. If no file exists, the request continues to your route handlers as normal. :::warning The AdonisJS static file server is convenient during development, but in production you should prefer serving static files through a reverse proxy (Nginx, Caddy, Traefik, Apache) or a CDN. These tools are purpose-built for static file delivery and offer better performance, caching, and compression than a Node.js process. This frees your AdonisJS server to focus on handling dynamic requests. See the [deployment guide](../../start/deployment.md#serving-static-files-in-production) for recommended production setups. ::: The key distinction in AdonisJS: files in the `public` directory are served as-is without any processing, while files in the `resources` directory are processed by your assets bundler (like Vite). Use `public` for files that are already in their final form. ## Installation The `@adonisjs/static` package comes pre-configured with the `web` starter kit. If you're using a different starter kit, you can install and configure it manually. Install and configure the package using the following command: ```sh node ace add @adonisjs/static ``` :::disclosure{title="See steps performed by the add command"} 1. Installs the `@adonisjs/static` package using the detected package manager. 2. Registers the following service provider inside the `adonisrc.ts` file. ```ts { providers: [ // ...other providers () => import('@adonisjs/static/static_provider') ] } ``` 3. Creates the `config/static.ts` file. 4. Registers the following middleware inside the `start/kernel.ts` file. ```ts server.use([ () => import('@adonisjs/static/static_middleware') ]) ``` ::: ## Configuration The configuration for the static middleware is stored in the `config/static.ts` file. ```ts title="config/static.ts" import { defineConfig } from '@adonisjs/static' const staticServerConfig = defineConfig({ enabled: true, etag: true, lastModified: true, dotFiles: 'ignore', }) export default staticServerConfig ``` ::::options :::option{name="enabled"} The `enabled` property allows you to temporarily disable the middleware without removing it from the middleware stack. This is useful when debugging or testing different configurations. Set it to `false` to stop serving static files while keeping the middleware registered. ```ts { enabled: true } ``` ::: :::option{name="etag"} The `etag` property controls whether the server generates [ETags](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/ETag) for cache validation. ETags help browsers determine if their cached version of a file is still valid without downloading it again. When a browser requests a file it has cached, it sends the ETag value. If the file hasn't changed, the server responds with a `304 Not Modified` status, saving bandwidth. This is enabled by default and should generally stay enabled for production. ```ts { etag: true } ``` ::: :::option{name="lastModified"} The `lastModified` property enables the [Last-Modified](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Last-Modified) header. The server uses the file's modification time from the file system (the [stat.mtime](https://nodejs.org/api/fs.html#statsmtime) property) as the header value. Browsers can use this header along with ETags for cache validation. Like ETags, this is enabled by default. ```ts { lastModified: true } ``` ::: :::option{name="dotFiles"} The `dotFiles` property defines how to handle requests for files starting with a dot (like `.env` or `.gitignore`). You can set one of three values: `'ignore'` (default), `'deny'`, or `'allow'`. The `'ignore'` option pretends dot files don't exist and returns a `404` status code. This is the recommended setting for security. The `'deny'` option explicitly denies access with a `403` status code. The `'allow'` option serves dot files like any other file. ```ts { dotFiles: 'ignore' // Recommended } ``` :::warning Setting `dotFiles` to `'allow'` can expose sensitive files like `.env` or `.git` directories if they're accidentally placed in the public folder. The `'ignore'` setting (default) is recommended for security. It returns a `404` response as if the file doesn't exist, preventing information disclosure. If you need to serve specific files for domain verification (like `.well-known/acme-challenge` for SSL certificates), create a subdirectory without a leading dot and configure your verification tool to use that path instead. ::: ::: :::option{name="acceptRanges"} The `acceptRanges` property allows browsers to resume interrupted downloads instead of restarting from the beginning. When enabled, the server adds an `Accept-Ranges` header to responses. This is particularly useful for large files like videos or software downloads. The property defaults to `true`. ```ts { acceptRanges: true } ``` ::: :::option{name="cacheControl"} The `cacheControl` property enables the [Cache-Control](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cache-Control) header. This header tells browsers and CDNs how long to cache files before checking for updates. When enabled, you can use the `maxAge` and `immutable` properties to fine-tune caching behavior. ```ts { cacheControl: true } ``` ::: :::option{name="maxAge"} The `maxAge` property sets the [max-age](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cache-Control#max-age) directive for the `Cache-Control` header. This tells browsers how long they can cache the file before checking for updates. You can specify the value in milliseconds or as a time expression string like `'30 mins'`, `'1 day'`, or `'1 year'`. ```ts { cacheControl: true, maxAge: '30 days' } ``` ::: :::option{name="immutable"} The `immutable` property adds the [immutable](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cache-Control#immutable) directive to the `Cache-Control` header. This tells browsers that the file will never change during its cache lifetime, allowing more aggressive caching. Use this for files with versioned or hashed filenames (like `app-v2.css` or `bundle-abc123.js`). By default, `immutable` is disabled. ```ts { cacheControl: true, maxAge: '1 year', immutable: true } ``` :::tip The `immutable` directive only works when `maxAge` is also set. If you enable `immutable` without setting `maxAge`, browsers will ignore it. This prevents accidental long-term caching without an explicit expiration time. ::: ::: :::option{name="headers"} The `headers` property accepts a function that returns custom HTTP headers for specific files. The function receives the file path as the first argument and the [file stats](https://nodejs.org/api/fs.html#class-fsstats) object as the second argument. This allows you to set different headers based on file type, size, or other attributes. The function should return an object where keys are header names and values are header values. If the function returns `undefined` or doesn't return anything, no additional headers are added for that file. ```ts { headers: (path, stats) => { /** * Set custom content type for .mc2 files * since they're not recognized by default */ if (path.endsWith('.mc2')) { return { 'content-type': 'application/octet-stream' } } /** * Add security headers for HTML files * to prevent XSS attacks */ if (path.endsWith('.html')) { return { 'X-Content-Type-Options': 'nosniff', 'X-Frame-Options': 'DENY' } } } } ``` ::: :::: ## Serving static files Once the middleware is registered, you can create files inside the `public` directory and access them in the browser using their file path. For example, the `./public/css/style.css` file can be accessed at `http://localhost:3333/css/style.css`. Here's what a typical `public` directory looks like in production: ```sh public/ ├── favicon.ico # Browser tab icon ├── robots.txt # Search engine crawling instructions ├── sitemap.xml # SEO sitemap for search engines ├── images/ │ ├── logo.png # Static logo (doesn't need optimization) │ └── og-image.jpg # Social media preview image ├── downloads/ │ ├── user-guide.pdf # Downloadable documentation │ └── terms.pdf # Legal documents └── uploads/ └── avatars/ # User-uploaded profile pictures ``` Each of these files would be accessible at its corresponding URL: - `http://localhost:3333/favicon.ico` - `http://localhost:3333/images/logo.png` - `http://localhost:3333/downloads/user-guide.pdf` ## `public` directory vs `resources` directory Understanding when to use the `public` directory versus the `resources` directory is crucial for organizing your application's assets correctly. Use the `public` directory for files that are already in their final form and don't need any processing: - Favicons and app icons - `robots.txt` and `sitemap.xml` files - Static images that don't need optimization (logos, icons) - Downloadable files (PDFs, ZIP archives, executables) - Third-party JavaScript libraries you want to serve as-is - User-uploaded content (avatars, documents) Use the `resources` directory with an assets bundler for files that need compilation or optimization: - CSS/SCSS files that need compilation - Modern JavaScript/TypeScript that needs transpilation - Images that benefit from optimization and responsive variants - Assets that need versioning or cache-busting hashes - Any file that should be processed by Vite or your build pipeline ```sh title="❌Source files in public won't be compiled" public/styles/main.scss # This won't be compiled public/app.ts # This won't be transpiled ``` ```sh title="✅ Source files in resources will be processed" resources/css/main.scss # Compiled by Vite resources/js/app.ts # Transpiled by Vite ``` :::tip A common mistake is placing source files (like `.scss` or modern `.ts`) in the `public` directory and expecting them to be compiled. The static middleware serves files exactly as they are without any processing. Source files that need compilation should go in the `resources` directory and be processed by Vite or your assets bundler. ::: ## Copying static files to production The files in your `public` directory are automatically copied to the `build` folder when you run the `node ace build` command. This ensures your static files are available in production alongside your compiled application code. The rule for copying public files is defined in the `adonisrc.ts` file: ```ts title="adonisrc.ts" { metaFiles: [ { pattern: 'public/**', reloadServer: false } ] } ``` The `pattern` property uses glob syntax to match all files inside the `public` directory. The `reloadServer: false` setting indicates that changes to these files during development don't require restarting the development server. If you add files to the `public` directory while your development server is running, you don't need to restart. The static middleware will serve them immediately. However, if you modify the `config/static.ts` file, you will need to restart the server for the configuration changes to take effect. ## See also - [Middleware guide](./middleware.md) - Learn more about how middleware works in AdonisJS - [Vite integration](../frontend/vite.md) - Set up asset compilation for the resources directory - [File uploads guide](./file_uploads.md) - Handle user-uploaded files that go in the public directory --- # Edge Templates This guide covers using Edge templates in AdonisJS applications. You will learn how to render templates from controllers, pass data to templates, work with layouts and components, use the pre-built components from the Hypermedia starter kit, and debug template issues. ## Overview Edge is a server-side templating engine for Node.js that allows you to compose HTML markup on the server and send the final static HTML to the browser. Since templates execute entirely on the server-side, they can tap into core framework features like authentication, authorization checks, and the translations system. When you create a Hypermedia application in AdonisJS, Edge comes pre-configured and ready to use. Templates are stored in the `resources/views` directory with the `.edge` file extension, and you render them from your route handlers or controllers using the `view` property from the HTTP context. Edge has comprehensive documentation at [edgejs.dev](https://edgejs.dev), which covers the template syntax, components system, and all available features in detail. This guide focuses specifically on using Edge within AdonisJS applications and introduces the pre-built components included in the Hypermedia starter kit. ## Your first template Let's create a simple page that displays a list of blog posts. This example demonstrates the fundamental workflow of rendering templates in AdonisJS. ::::steps :::step{title="Create the template file"} Generate a new template using the Ace command. ```bash node ace make:view pages/posts/index # CREATE: resources/views/pages/posts/index.edge ``` ::: :::step{title="Add the template content"} Open `resources/views/pages/posts/index.edge` and add the following content. ```edge title="resources/views/pages/posts/index.edge" @layout() @each(post in posts)

{{ post.title }}

{{{ excerpt(post.content, 280) }}}

@end @end ``` A few important things to understand about this template: - The `@layout()` component wraps your content with a complete HTML document structure (including ``, ``, and `` tags). We'll explore layouts in detail later in this guide. - The `@each` tag loops over the `posts` array and renders the content for each post. Edge provides several tags like `@if`, `@else`, and `@elseif` for writing logic in templates. You can learn about all available tags in the [Edge syntax reference](https://edgejs.dev/docs/syntax_specification). - The double curly braces `{{ }}` evaluate and output a JavaScript expression. The triple curly braces `{{{ }}}` do the same but don't escape HTML, which is useful for rendering rich content. ::: :::step{title="Define the route"} Create a route to handle requests to the posts page. ```ts title="start/routes.ts" import router from '@adonisjs/core/services/router' import { controllers } from '#generated/controllers' router.get('posts', [controllers.PostsController, 'index']) ``` ::: :::step{title="Create the controller"} Create a controller that renders the template with post data. ```ts title="app/controllers/posts_controller.ts" import Post from '#models/post' import type { HttpContext } from '@adonisjs/core/http' export default class PostsController { async index({ view }: HttpContext) { /** * Render the template located at resources/views/pages/posts/index.edge * The first parameter is the template path (relative to resources/views) * The second parameter is the template state (data to share with the template) */ return view.render('pages/posts/index', { posts: await Post.all(), }) } } ``` ::: :::step{title="View the result"} Visit `http://localhost:3333/posts` in your browser to see the rendered page. ::: :::: You can also render templates directly from routes without using a controller. ```ts title="start/routes.ts" import router from '@adonisjs/core/services/router' /** * The router.on().render() shorthand renders a template directly. * The first parameter is the template path. */ router.on('/').render('pages/home') ``` ## Understanding template state The data object you pass to `view.render()` is called the **template state**. All properties in this object become available as variables in your template. In addition to the data you explicitly pass, AdonisJS automatically shares certain globals with every template: - The `request` object for accessing request data - The `auth` object for checking authentication status - Edge helpers like `excerpt()`, `truncate()`, and route helpers You can view all available helpers and global properties in the [Edge reference guide](/reference/edge). ## Template syntax refresher Edge uses a combination of curly braces and tags to add dynamic behavior to your templates. Here's a quick refresher of the most common syntax patterns: **Outputting variables:** ```edge {{ post.title }} ``` **Outputting unescaped HTML:** ```edge {{{ post.content }}} ``` **Conditionals:** ```edge @if(user)

Welcome back, {{ user.name }}

@else

Please log in

@end ``` **Loops:** ```edge @each(post in posts)

{{ post.title }}

@end ``` **Evaluating JavaScript expressions:** ```edge {{ post.createdAt.toFormat('dd LLL yyyy') }} {{ posts.length > 0 ? 'Posts available' : 'No posts yet' }} ``` For complete coverage of Edge's template syntax, including advanced features like partials, slots, and custom tags, refer to the [Edge syntax reference](https://edgejs.dev/docs/syntax_specification). ## Working with layouts and components The `@layout()` component you saw in the first example wraps your page content with a complete HTML document structure. This component is stored at `resources/views/components/layout.edge` and contains the standard HTML boilerplate: ```edge title="resources/views/components/layout.edge" My App {{{ await $slots.main() }}} ``` The `$slots.main()` method call renders whatever content you place between the opening and closing `@layout()` tags in your page templates. This is Edge's slots feature, which allows components to accept content from their consumers. ### Creating components Components in Edge are reusable template fragments stored in the `resources/views/components` directory. Any template file in this directory becomes available as an Edge tag. For example, if you create a file at `resources/views/components/card.edge`: ```edge title="resources/views/components/card.edge"
{{{ await $slots.main() }}}
``` You can use it in your templates like this: ```edge @card()

Card title

Card content

@end ``` Components can accept props (parameters) and have multiple named slots for more complex compositions. For a complete guide to building and using components, see the [Edge components guide](https://edgejs.dev/docs/components/introduction). ## Starter kit components The Hypermedia starter kit includes a collection of unstyled components for building forms and common UI patterns. Each component renders at most one HTML element and passes unknown props through as HTML attributes, allowing you to apply classes and other attributes directly. ### Layout Renders the HTML document with head and body elements. - **Props**: None - **Slots**: `main` (default) ```edge @layout()
Page content goes here
@end ``` ### Form Renders an HTML form element with automatic CSRF token injection. | Prop | Type | Description | |------|------|-------------| | `action` | `string` | The form action URL | | `method` | `string` | HTTP method. Supports `PUT`, `PATCH`, and `DELETE` via method spoofing | | `route` | `string` | Compute action URL from a named route | | `routeParams` | `array` | Parameters for the named route | | `routeOptions` | `object` | Additional options for URL generation (e.g., query strings) | ```edge @form({ route: 'posts.store', method: 'POST' }) {{-- Form fields --}} @end @form({ route: 'posts.update', method: 'PUT', routeParams: [post.id] }) {{-- Form fields --}} @end ``` ### Field components Form field components work together to create accessible form inputs with labels and validation error display. #### field.root Container that establishes context for child field components. | Prop | Type | Description | |------|------|-------------| | `name` | `string` | Field name, used for error message lookup | | `id` | `string` | Element ID, used to associate labels with inputs | #### field.label Renders a label element associated with the field. | Prop | Type | Description | |------|------|-------------| | `text` | `string` | Label text (alternative to using the slot) | #### field.error Displays validation errors for the field. Automatically looks up errors by the field name. ```edge @field.root({ name: 'email' }) @!field.label({ text: 'Email address' }) @!input.control({ type: 'email', autocomplete: 'email' }) @!field.error() @end ``` ### Input control Renders an input element. Must be a child of `@field.root`. It passes all props as HTML attributes to the input element. ```edge @field.root({ name: 'username' }) @!field.label({ text: 'Username' }) @!input.control({ type: 'text', minlength: '3', maxlength: '20' }) @!field.error() @end ``` ### Select control Renders a select element with options. Must be a child of `@field.root`. | Prop | Type | Description | |------|------|-------------| | `options` | `array` | Array of objects with `name` and `value` properties | ```edge @field.root({ name: 'country' }) @!field.label({ text: 'Country' }) @!select.control({ options: countries.map((country) => ({ name: country.name, value: country.code })) }) @!field.error() @end ``` ### Textarea control Renders a textarea element. Must be a child of `@field.root`. It passes all props as HTML attributes to the textarea element. ```edge @field.root({ name: 'bio' }) @!field.label({ text: 'Biography' }) @!textarea.control({ rows: '4' }) @!field.error() @end ``` ### Checkbox components Checkbox components create checkbox inputs with shared naming for form submission. #### checkbox.group Container that establishes the shared name for child checkboxes. | Prop | Type | Description | |------|------|-------------| | `name` | `string` | Shared name for all checkboxes in the group | #### checkbox.control Renders a checkbox input. Must be nested within both `@checkbox.group` and `@field.root`. It passes all props as HTML attributes. Use `value` to set the checkbox value. ```edge @checkbox.group({ name: 'services' }) @field.root({ id: 'design' }) @!checkbox.control({ value: 'design' }) @!field.label({ text: 'Design' }) @end @field.root({ id: 'development' }) @!checkbox.control({ value: 'development' }) @!field.label({ text: 'Development' }) @end @field.root({ id: 'marketing' }) @!checkbox.control({ value: 'marketing' }) @!field.label({ text: 'Marketing' }) @end @end ``` ### Radio components Radio components create mutually exclusive options within a group. #### radio.group Container that establishes the shared name for child radio buttons. | Prop | Type | Description | |------|------|-------------| | `name` | `string` | Shared name for all radio buttons in the group | #### radio.control Renders a radio input. Must be nested within both `@radio.group` and `@field.root`. It passes all props as HTML attributes. Use `value` to set the radio value. ```edge @radio.group({ name: 'payment_plan' }) @field.root({ id: 'free' }) @!radio.control({ value: 'free' }) @!field.label({ text: 'Free' }) @end @field.root({ id: 'pro' }) @!radio.control({ value: 'pro' }) @!field.label({ text: 'Pro $29/month' }) @end @field.root({ id: 'enterprise' }) @!radio.control({ value: 'enterprise' }) @!field.label({ text: 'Custom pricing' }) @end @end ``` ### Alert components Alert components display notification messages with optional auto-dismiss behavior. #### alert.root Container that establishes context for alert title and description. | Prop | Type | Description | |------|------|-------------| | `variant` | `string` | Alert variant (e.g., `destructive`, `success`) | | `autoDismiss` | `boolean` | Whether the alert should dismiss automatically | #### alert.title Renders the alert heading. | Prop | Type | Description | |------|------|-------------| | `text` | `string` | Title text (alternative to using the slot) | #### alert.description Renders the alert body text. | Prop | Type | Description | |------|------|-------------| | `text` | `string` | Description text (alternative to using the slot) | ```edge @alert.root({ variant: 'destructive', autoDismiss: true }) @!alert.title({ text: 'Unauthorized' }) @!alert.description({ text: 'You are not allowed to access this page' }) @end ``` ### Button Renders a button element. | Prop | Type | Description | |------|------|-------------| | `text` | `string` | Button text (alternative to using the slot) | ```edge @!button({ text: 'Sign up', type: 'submit' }) @button({ type: 'button', class: 'btn-secondary' }) Cancel @end ``` ### Link Renders an anchor element with route-based URL generation. | Prop | Type | Description | |------|------|-------------| | `text` | `string` | Link text (alternative to using the slot) | | `route` | `string` | Compute href from a named route | | `routeParams` | `array` | Parameters for the named route | | `routeOptions` | `object` | Additional options for URL generation | | `href` | `string` | Direct URL (use instead of route) | ```edge @!link({ route: 'posts.show', routeParams: [post.id], text: 'View post' }) @link({ route: 'posts.edit', routeParams: [post.id] }) Edit {{-- icon --}} @end ``` ### Avatar Renders either an image or initials for user avatars. | Prop | Type | Description | |------|------|-------------| | `src` | `string` | Avatar image URL (renders an `img` element) | | `initials` | `string` | Fallback initials (renders a `span` element) | ```edge {{-- With image --}} @!avatar({ src: user.avatarUrl, alt: user.name }) {{-- With initials --}} @!avatar({ initials: user.initials }) ``` ## Debugging templates When working with templates, you may need to inspect the data available in your template or debug why certain values aren't displaying as expected. Edge provides the `@dump` tag for this purpose. The `@dump` tag pretty-prints the value of a variable, making it easy to inspect data structures: ```edge @dump(posts) ``` To view the entire template state (all variables available in your template), use: ```edge @dump(state) ``` The output appears in your rendered HTML, showing the structure and values of your data. During development, templates automatically reload when you make changes, so you'll see updates immediately in your browser without restarting the server. ## Configuration Edge comes pre-configured in Hypermedia applications and works out of the box. However, you can customize Edge by creating a preload file if you need to register custom helpers, tags, or plugins. Create a preload file for Edge configuration: ```bash node ace make:preload view ``` This creates a preload file where you can customize Edge before your application starts. Inside this file, you can register Edge globals, plugins, and custom tags. If you need to customize the directory where templates are stored, you can modify the `directories` option in your `adonisrc.ts` file. See the [AdonisRC reference guide](/reference/adonisrc-rcfile) for more details on configuration options. ## See also - [Edge syntax reference](https://edgejs.dev/docs/syntax_specification) - Learn about all template syntax features, tags, and expressions - [Edge components guide](https://edgejs.dev/docs/components/introduction) - Deep dive into building and composing components - [Edge reference guide](/reference/edge) - View all available helpers and global properties in Edge templates - [Edge documentation](https://edgejs.dev) - Complete Edge documentation with advanced features and patterns --- # Inertia This guide covers using Inertia with AdonisJS to build single-page applications. You will learn how to: - Render Inertia pages from controllers and routes and pass props to frontend components - Scaffold page components with the `make:page` command - Structure the `inertia/` directory and understand key configuration files - Generate end-to-end types for pages and shared data - Use data loading patterns like optional, deferred, and mergeable props - Build forms and navigation with the `Link` and `Form` components - Share data globally and scope validation errors with error bags - Customize the root Edge template with the `@inertia` and `@inertiaHead` tags - Control redirects, browser history, and history encryption - Enable server-side rendering (SSR) - Understand the request lifecycle in Inertia applications ## Overview Inertia acts as a bridge between AdonisJS and frontend frameworks like React and Vue. It eliminates the need for client-side routing or complex state management libraries by embracing a server-first architecture. You write controllers and routes exactly as you would in a traditional server-rendered application, but instead of returning HTML or JSON, you render Inertia pages that your frontend framework displays. This approach gives you the best of both worlds: the simplicity of server-side routing and data fetching combined with the rich interactivity of React or Vue for the view layer. AdonisJS officially supports both frameworks through the Inertia starter kit. See also: [How Inertia works](https://inertiajs.com/how-it-works) on the official Inertia documentation. ## Basic example Let's walk through rendering a posts list end-to-end. The flow has three pieces: a route, a controller that calls `inertia.render()`, and a page component inside `inertia/pages/`. ::::steps :::step{title="Register a route"} Routes look identical to any other AdonisJS route. There is no special routing layer for Inertia. ```ts title="start/routes.ts" router.get('/posts', [controllers.Posts, 'index']) ``` ::: :::step{title="Render a page from the controller"} The HTTP context exposes an `inertia` object. Call `inertia.render()` with two arguments: the page component path (relative to `inertia/pages/`) and an object of props the component receives. ```ts title="app/controllers/posts_controller.ts" import Post from '#models/post' import type { HttpContext } from '@adonisjs/core/http' import PostTransformer from '#transformers/post_transformer' export default class PostsController { async index({ inertia }: HttpContext) { const posts = await Post.all() return inertia.render('posts/index', { posts: PostTransformer.transform(posts) }) } } ``` Use a [transformer](./transformers.md) to serialize model instances into plain objects. Transformers also generate frontend types under the `Data` namespace, keeping props in sync with the backend. ::: :::step{title="Create the page component"} The string `'posts/index'` resolves to `inertia/pages/posts/index.tsx` (or `.vue`). Scaffold the file with `node ace make:page posts/index`. The component receives the props from `inertia.render()` directly. ::::tabs :::tab{title="React"} ```tsx title="inertia/pages/posts/index.tsx" import { InertiaProps } from '~/types' import { Data } from '@generated/data' type PageProps = InertiaProps<{ posts: Data.Post[] }> export default function PostsIndex({ posts }: PageProps) { return ( <> {posts.map((post) => (

{post.title}

))} ) } ``` ::: :::tab{title="Vue"} ```vue title="inertia/pages/posts/index.vue" ``` ::: :::: The `InertiaProps` helper merges your page-specific props with [shared data](#shared-data), so global props like `user` or `flash` are typed alongside `posts`. ::: :::: ### Rendering from a route For pages without controller logic, skip the controller and render directly from the route definition using `renderInertia()`. ```ts title="start/routes.ts" router.on('/about').renderInertia('about') router.on('/pricing').renderInertia('marketing/pricing', { plans: ['starter', 'pro', 'enterprise'], }) ``` The component name is type-checked against the generated `InertiaPages` interface, so typos are caught at compile time. ### What happens behind the scenes On the very first request to `/posts`, Inertia returns an HTML shell containing a root `
` with the page component name and serialized props as a `data-page` attribute. The frontend bundle reads that attribute and boots React or Vue. For every subsequent navigation (link clicks, form submits) Inertia issues a `fetch` request with an `X-Inertia` header. The server runs the same controller but returns a JSON page object instead of HTML. The client swaps in the new component and updates the URL. No full page reload, no separate API. ## The inertia directory The `inertia/` directory contains your frontend application. Here is the structure created by the starter kit: ``` inertia/ ├── app.tsx (or app.vue) # Frontend application entrypoint ├── client.ts # Tuyau API client setup ├── ssr.tsx (or ssr.vue) # SSR entrypoint (when enabled) ├── tsconfig.json # TypeScript config for frontend code ├── types.ts # Shared type definitions ├── css/ │ └── app.css # Global styles ├── layouts/ # Reusable layout components │ └── default.tsx └── pages/ # Page components rendered by controllers └── home.tsx ``` The `pages/` directory is where Inertia looks for components when you call `inertia.render()`. The path you pass (like `posts/index`) maps directly to a file in this directory (`inertia/pages/posts/index.tsx`). The `app.tsx` (or `app.vue`) file is the entrypoint that boots your frontend application. It initializes Inertia with your page components and any global configuration. The `ssr.tsx` file serves the same purpose for server-side rendering. You can create additional directories as your project grows, such as `components/` for shared UI elements or `hooks/` for custom React hooks. ## Configuration files Two configuration files control how Inertia works in your AdonisJS application. The `config/inertia.ts` file defines the Inertia adapter settings. ```ts title="config/inertia.ts" import { defineConfig } from '@adonisjs/inertia' const inertiaConfig = defineConfig({ rootView: 'inertia_layout', ssr: { enabled: false, entrypoint: 'inertia/ssr.tsx', }, }) export default inertiaConfig ``` The supported options are: ::::options :::option{name="rootView" type="string | (ctx) => string"} The Edge template that renders the initial HTML shell. Defaults to `inertia_layout`. Pass a function to choose a different template per request, for example to render a marketing layout for unauthenticated users. ```ts rootView: (ctx) => ctx.auth.isAuthenticated ? 'app_layout' : 'marketing_layout' ``` ::: :::option{name="encryptHistory" type="boolean"} Encrypts sensitive page props stored in the browser's history state. Defaults to `false`. See [history encryption](https://inertiajs.com/history-encryption) on the Inertia documentation. ::: :::option{name="assetsVersion" type="string | number"} Pins the asset version string used for [asset versioning](#asset-versioning). When omitted, the version is computed from the Vite manifest. Set this to override the default with a git commit hash, build timestamp, or any custom identifier. ::: :::option{name="ssr.enabled" type="boolean"} Enables server-side rendering. See [Server-side rendering](#server-side-rendering). ::: :::option{name="ssr.entrypoint" type="string"} Path to the SSR entrypoint file relative to the project root. Defaults to `inertia/ssr.tsx`. ::: :::option{name="ssr.bundle" type="string"} Path to the production SSR bundle generated by Vite. Defaults to `ssr/ssr.js`. ::: :::option{name="ssr.pages" type="string[] | (ctx, page) => boolean"} Restricts SSR to a subset of pages. Pass an array of component names, or a function that returns a boolean for each page. ```ts ssr: { enabled: true, entrypoint: 'inertia/ssr.tsx', pages: ['home', 'marketing/pricing'], } ``` ::: :::: The `resources/views/inertia_layout.edge` template renders the initial HTML shell that contains the root `div` where your frontend application mounts. See [Root template](#root-template) for the available Edge tags. ## Root template The Edge template configured under `rootView` is rendered for the very first request. It contains the root element where your frontend application mounts and any HTML the SSR output needs to slot into. The Inertia package registers two Edge tags for this template. ```edge title="resources/views/inertia_layout.edge" @inertiaHead() @vite(['inertia/app.tsx']) @inertia() ``` The `@inertia` tag renders a `div` with the encoded page object as a `data-page` attribute. The frontend reads this attribute to boot the SPA. By default, the element is `
`. Pass an object to override the tag, id, or class. ```edge @inertia({ as: 'main', id: 'app-root', class: 'min-h-screen' }) ``` The `@inertiaHead` tag outputs the head fragments (title, meta tags) collected during server-side rendering. Include it whenever SSR is enabled. It is a no-op for non-SSR responses. ### Passing data to the template The third argument to `inertia.render()` is forwarded to the root template as view props. Use this for values that belong in the HTML shell rather than as page props, such as the page title or `` tags for non-SSR pages. ```ts title="app/controllers/posts_controller.ts" return inertia.render( 'posts/show', { post: PostTransformer.transform(post) }, { title: post.title, description: post.summary } ) ``` ```edge title="resources/views/inertia_layout.edge" {{ title ?? 'My App' }} @if(description) @end @inertiaHead() ``` ## Generated types Inertia in AdonisJS is fully type-safe end to end. Two generated artifacts power this: - The `Data` namespace at `.adonisjs/client/data.d.ts` mirrors transformer output, so props passed from the controller are typed in the page component. See [Transformers](./transformers.md). - The `InertiaPages` interface at `.adonisjs/server/pages.d.ts` maps each file in `inertia/pages/` to its component prop types. This is what makes `inertia.render('posts/index', { posts })` autocomplete and type-check the component name and props. The `InertiaPages` types are produced by the `indexPages` Assembler hook. Register it in `adonisrc.ts`, passing the framework you use. ```ts title="adonisrc.ts" import { defineConfig } from '@adonisjs/core/app' import { indexPages } from '@adonisjs/inertia/index_pages' export default defineConfig({ hooks: { onDevServerStarted: [indexPages({ framework: 'react' })], onBuildStarting: [indexPages({ framework: 'react' })], }, }) ``` The `framework` option accepts `'vue3'` or `'react'`. Pass `source` to scan a directory other than `inertia/pages`. ### Typing shared data Shared data returned from the Inertia middleware is available on every page through the `InertiaProps` helper. To make it type-safe, augment the `SharedProps` interface with the inferred return type of your `share()` method. ```ts title="app/middleware/inertia_middleware.ts" import type { HttpContext } from '@adonisjs/core/http' import type { InferSharedProps } from '@adonisjs/inertia/types' export default class InertiaMiddleware { share(ctx: HttpContext) { return { user: ctx.auth?.user, flash: ctx.session?.flashMessages.all(), } } } declare module '@adonisjs/inertia/types' { interface SharedProps extends InferSharedProps {} } ``` Once augmented, `props.user` and `props.flash` are typed inside every page component without redeclaring them. ## Data loading patterns Inertia provides several patterns for loading data efficiently. AdonisJS exposes helpers on the `inertia` object to support each pattern. :::tip Optional and deferred props look similar but behave differently. Optional props are evaluated **only** when the frontend explicitly asks for them through a partial reload. Deferred props are evaluated on a follow-up request that Inertia issues automatically right after the page mounts. Reach for `optional` when the value is rarely needed (a tab a user may never click) and `defer` when the value is always needed but slow to compute (a dashboard chart). ::: ### Optional props Optional props are only evaluated when the frontend explicitly requests them during a partial reload. This is useful for expensive queries that aren't needed on every page load. ```ts title="app/controllers/users_controller.ts" return inertia.render('users/index', { /** * The database query only runs when the frontend * includes 'users' in a partial reload request. */ users: inertia.optional(async () => { const users = await User.all() return UserTransformer.transform(users) }) }) ``` See also: [Partial reloads](https://inertiajs.com/partial-reloads) on the Inertia documentation. ### Always props The `always` helper ensures a prop is always included in responses, even during partial reloads that don't explicitly request it. This is the opposite of optional props. ```ts title="app/controllers/users_controller.ts" return inertia.render('users/index', { /** * Permissions are always computed and included, * regardless of what the frontend requests. */ permissions: inertia.always(async () => { const permissions = await Permissions.all() return PermissionTransformer.transform(permissions) }) }) ``` ### Deferred props Deferred props are loaded after the initial page render, allowing the page to display immediately while slower data loads in the background. The frontend shows a loading state until the deferred data arrives. ```ts title="app/controllers/dashboard_controller.ts" return inertia.render('dashboard', { /** * These props load after the page renders. * The frontend can show loading indicators. */ metrics: inertia.defer(async () => { return computeMetrics() }), newSignups: inertia.defer(async () => { return getNewSignups() }) }) ``` You can group deferred props so they load together in a single request. ```ts title="app/controllers/dashboard_controller.ts" return inertia.render('dashboard', { /** * Both props are fetched in the same deferred request * because they share the 'dashboard' group name. */ metrics: inertia.defer(async () => { return computeMetrics() }, 'dashboard'), newSignups: inertia.defer(async () => { return getNewSignups() }, 'dashboard') }) ``` See also: [Deferred props](https://inertiajs.com/deferred-props) on the Inertia documentation. ### Mergeable props Mergeable props are merged with existing frontend data rather than replacing it. This is useful for infinite scrolling or appending new items to a list. ```ts title="app/controllers/users_controller.ts" return inertia.render('users/index', { /** * New notifications are merged with existing ones * instead of replacing the entire array. */ notifications: inertia.merge(await fetchNotifications()) }) ``` You can combine merging with deferred loading by chaining the `merge()` method. ```ts title="app/controllers/users_controller.ts" return inertia.render('users/index', { notifications: inertia.defer(() => { return fetchNotifications() }).merge() }) ``` By default, data is shallow merged. For nested objects that need recursive merging, use `deepMerge()` instead. ```ts title="app/controllers/users_controller.ts" return inertia.render('users/index', { notifications: inertia.defer(() => { return fetchNotifications() }).deepMerge() }) ``` See also: [Merging props](https://inertiajs.com/merging-props) on the Inertia documentation. ## Link and Form components Inertia provides `Link` and `Form` components for navigation and form submissions. AdonisJS wraps these components with additional functionality that lets you reference routes by name instead of hardcoding URLs. Import the components from the AdonisJS package rather than directly from Inertia. ::::tabs :::tab{title="React"} ```tsx // [!code --] import { Form, Link } from '@inertiajs/react' // [!code ++] import { Form, Link } from '@adonisjs/inertia/react' ``` ::: :::tab{title="Vue"} ```vue ``` ::: :::: ### Creating links The `Link` component creates navigation links using route names defined in your AdonisJS routes. ```tsx Signup Login ``` ### Creating forms The `Form` component handles form submissions with automatic CSRF protection and error handling. ::::tabs :::tab{title="React"} ```tsx title="inertia/pages/posts/edit.tsx" import { Form } from '@adonisjs/inertia/react' export default function EditPost({ post }) { return ( {({ errors }) => ( <>
{errors.title &&
{errors.title}
}
)} ) } ``` ::: :::tab{title="Vue"} ```vue title="inertia/pages/posts/edit.vue" ``` ::: :::: The `Form` component infers the HTTP method (`POST`, `PUT`, `PATCH`, `DELETE`) from the route name automatically. You do not need to pass a `method` prop — in fact, the AdonisJS wrapper omits `method` and `action` from the accepted props since both are derived from the route definition. When validation fails on the server, AdonisJS automatically adds validation errors to the session flash messages. The Inertia middleware then shares these errors with the frontend, making them available through the `errors` object in your form. ### Scoping errors with error bags When a page renders multiple independent forms, errors from one form will leak into the others because they all read from the same `errors` object. To isolate them, set the `errorBag` prop on the form. Inertia sends this name in the `X-Inertia-Error-Bag` header, and the middleware nests the validation errors under that key. ```tsx
{({ errors }) => ( /** * errors.newComment.body holds errors from this form only. */
``` The helper generates a hidden input field that Shield's middleware validates on submission. ```html title="Output HTML"
``` ### Handling CSRF errors Shield raises an `E_BAD_CSRF_TOKEN` exception when a token is missing or invalid. By default, AdonisJS redirects the user back to the form with an error flash message. You can display this message in your template using the `@error` tag. ```edge title="resources/views/posts/create.edge" @error('E_BAD_CSRF_TOKEN')

{{ $message }}

@end
{{ csrfField() }} {{-- form fields --}}
``` For custom error handling, you can catch the exception in your global exception handler. This is useful when you want to render a custom error page or return a specific response format. ```ts title="app/exceptions/handler.ts" import app from '@adonisjs/core/services/app' import { errors } from '@adonisjs/shield' import { HttpContext, ExceptionHandler } from '@adonisjs/core/http' export default class HttpExceptionHandler extends ExceptionHandler { async handle(error: unknown, ctx: HttpContext) { /** * Check if the error is a CSRF token error and return * a custom response instead of the default redirect. */ if (error instanceof errors.E_BAD_CSRF_TOKEN) { return ctx.response .status(error.status) .send('Your session has expired. Please refresh the page and try again.') } return super.handle(error, ctx) } } ``` ### Enabling CSRF tokens for Ajax requests Single-page applications and interactive interfaces often submit forms via JavaScript instead of traditional form submissions. For these cases, Shield can expose the CSRF token in a cookie that your frontend code can read. When `enableXsrfCookie` is enabled, Shield stores the token in an encrypted cookie named `XSRF-TOKEN`. Frontend libraries like Axios automatically read this cookie and include it as an `X-XSRF-TOKEN` header with every request. ```ts title="config/shield.ts" import { defineConfig } from '@adonisjs/shield' const shieldConfig = defineConfig({ csrf: { enabled: true, exceptRoutes: [], enableXsrfCookie: true, // [!code highlight] methods: ['POST', 'PUT', 'PATCH', 'DELETE'], }, }) export default shieldConfig ``` :::tip Only enable `enableXsrfCookie` if your application makes Ajax requests. For traditional server-rendered forms that use full page submissions, the hidden input field is sufficient and more secure. ::: ### Exempting routes from CSRF protection API endpoints that receive webhooks or requests from external services cannot include CSRF tokens. You can exempt specific routes using the `exceptRoutes` option. ```ts title="config/shield.ts" import { defineConfig } from '@adonisjs/shield' const shieldConfig = defineConfig({ csrf: { enabled: true, exceptRoutes: [ '/api/webhooks/*', '/api/payments/callback', ], enableXsrfCookie: false, methods: ['POST', 'PUT', 'PATCH', 'DELETE'], }, }) export default shieldConfig ``` For dynamic exemption logic, pass a function that receives the HTTP context and returns a boolean. ```ts title="config/shield.ts" import { defineConfig } from '@adonisjs/shield' const shieldConfig = defineConfig({ csrf: { enabled: true, exceptRoutes: (ctx) => { /** * Exempt all routes starting with /api/ since these * are consumed by external services with their own * authentication mechanisms. */ return ctx.request.url().startsWith('/api/') }, enableXsrfCookie: false, methods: ['POST', 'PUT', 'PATCH', 'DELETE'], }, }) export default shieldConfig ``` ### CSRF configuration reference | Option | Type | Description | |--------|------|-------------| | `enabled` | `boolean` | Turn CSRF protection on or off. | | `exceptRoutes` | `string[]` or `function` | Routes to exempt from CSRF protection. Accepts route patterns or a function receiving `HttpContext`. | | `enableXsrfCookie` | `boolean` | When `true`, stores the CSRF token in an `XSRF-TOKEN` cookie for Ajax requests. | | `methods` | `string[]` | HTTP methods that require CSRF token validation. Defaults to `POST`, `PUT`, `PATCH`, `DELETE`. | | `cookieOptions` | `object` | Configuration for the `XSRF-TOKEN` cookie. See [cookies configuration](../basics/response.md#cookie-options). | ## CSP (Content Security Policy) XSS (Cross-Site Scripting) attacks inject malicious scripts into your pages. An attacker might exploit a comment form that doesn't sanitize input, injecting JavaScript that steals user cookies or redirects them to phishing sites. Even with proper input sanitization, XSS vulnerabilities can slip through. CSP provides a second line of defense by telling browsers which sources of content are trusted. When you define a CSP policy, browsers will block any scripts, styles, or other resources that don't match your allowed sources. Even if an attacker manages to inject a script tag, the browser refuses to execute it because it wasn't loaded from a trusted source. ### Enabling CSP CSP is disabled by default because policies must be tailored to your application's needs. Enable it and define your directives in the configuration file. ```ts title="config/shield.ts" import { defineConfig } from '@adonisjs/shield' const shieldConfig = defineConfig({ csp: { enabled: true, directives: { defaultSrc: [`'self'`], scriptSrc: [`'self'`, 'https://cdnjs.cloudflare.com'], styleSrc: [`'self'`, 'https://fonts.googleapis.com'], fontSrc: [`'self'`, 'https://fonts.gstatic.com'], imgSrc: [`'self'`, 'data:', 'https://images.example.com'], }, reportOnly: false, }, }) export default shieldConfig ``` The `defaultSrc` directive acts as a fallback for any resource type you don't explicitly configure. The `'self'` keyword allows resources from your own domain. Each directive controls a specific resource type: `scriptSrc` for JavaScript, `styleSrc` for CSS, `fontSrc` for fonts, and so on. You can find the complete list of available directives at [content-security-policy.com](https://content-security-policy.com/#directive). ### Using nonces for inline scripts and styles Inline scripts and styles are blocked by default under CSP because they're a common XSS attack vector. However, you may need inline code for legitimate purposes. Nonces (number used once) allow specific inline blocks while keeping the general policy strict. Add the `@nonce` keyword to your directives, then include the `nonce` attribute on your inline script and style tags using the `cspNonce` variable available in Edge templates. ```ts title="config/shield.ts" import { defineConfig } from '@adonisjs/shield' const shieldConfig = defineConfig({ csp: { enabled: true, directives: { defaultSrc: [`'self'`], scriptSrc: [`'self'`, '@nonce'], styleSrc: [`'self'`, '@nonce'], }, reportOnly: false, }, }) export default shieldConfig ``` ```edge title="resources/views/pages/home.edge" ``` Shield generates a unique nonce for each request. Attackers cannot predict this value, so even if they inject a script tag, it won't have a valid nonce and the browser will block it. ### Configuring CSP for Vite When using Vite for asset bundling, you need to allow assets from the Vite dev server during development. Shield provides special keywords for this purpose. ```ts title="config/shield.ts" import { defineConfig } from '@adonisjs/shield' const shieldConfig = defineConfig({ csp: { enabled: true, directives: { defaultSrc: [`'self'`, '@viteDevUrl'], connectSrc: [`'self'`, '@viteHmrUrl'], scriptSrc: [`'self'`, '@nonce'], styleSrc: [`'self'`, '@nonce'], }, reportOnly: false, }, }) export default shieldConfig ``` The `@viteDevUrl` keyword resolves to the Vite development server URL, while `@viteHmrUrl` allows the WebSocket connection for hot module replacement. If you deploy bundled assets to a CDN, replace `@viteDevUrl` with `@viteUrl`. This keyword allows assets from both the development server and your production CDN. ```ts title="config/shield.ts" import { defineConfig } from '@adonisjs/shield' const shieldConfig = defineConfig({ csp: { enabled: true, directives: { defaultSrc: [`'self'`, '@viteUrl'], // [!code highlight] connectSrc: [`'self'`, '@viteHmrUrl'], scriptSrc: [`'self'`, '@nonce'], styleSrc: [`'self'`, '@nonce'], }, reportOnly: false, }, }) export default shieldConfig ``` :::warning Vite currently does not support adding `nonce` attributes to style tags it injects into the DOM during development. This is a [known limitation](https://github.com/vitejs/vite/pull/11864) being addressed by the Vite team. Until resolved, you may need to use `'unsafe-inline'` for `styleSrc` during development, then switch to nonce-based policies in production. ::: ### Testing policies with report-only mode A misconfigured CSP can break your application by blocking legitimate resources. Use `reportOnly` mode to test your policy without enforcement. In this mode, browsers report violations but don't block resources. ```ts title="config/shield.ts" import { defineConfig } from '@adonisjs/shield' const shieldConfig = defineConfig({ csp: { enabled: true, directives: { defaultSrc: [`'self'`], reportUri: ['/csp-report'], }, reportOnly: true, }, }) export default shieldConfig ``` Create an endpoint to collect violation reports. This helps you identify resources you forgot to whitelist before enabling enforcement. ```ts title="start/routes.ts" import router from '@adonisjs/core/services/router' router.post('/csp-report', async ({ request, logger }) => { const report = request.input('csp-report') logger.warn({ report }, 'CSP violation detected') }) ``` Once you've verified your policy isn't blocking legitimate resources, set `reportOnly` to `false` to enable enforcement. ### CSP configuration reference | Option | Type | Description | |--------|------|-------------| | `enabled` | `boolean` | Turn CSP on or off. | | `directives` | `object` | CSP directives defining allowed sources for each resource type. | | `reportOnly` | `boolean` | When `true`, violations are reported but not blocked. Use for testing policies. | ## HSTS (HTTP Strict Transport Security) When users type your domain without `https://`, browsers first connect over insecure HTTP before redirecting to HTTPS. This brief window allows attackers to intercept the initial request through man-in-the-middle attacks, potentially downgrading the connection or stealing sensitive data. HSTS tells browsers to always use HTTPS for your domain, even when users type `http://` or click plain HTTP links. After receiving the HSTS header, browsers automatically upgrade all requests to HTTPS for the specified duration, eliminating the insecure redirect window. ```ts title="config/shield.ts" import { defineConfig } from '@adonisjs/shield' const shieldConfig = defineConfig({ hsts: { enabled: true, maxAge: '180 days', includeSubDomains: true, }, }) export default shieldConfig ``` The `maxAge` option tells browsers how long to remember the HTTPS-only policy. The `includeSubDomains` option extends this protection to all subdomains, preventing attackers from exploiting insecure subdomains to compromise your main domain. :::warning Only enable HSTS after confirming HTTPS works correctly across your entire domain and all subdomains. Once browsers cache the HSTS policy, they will refuse to connect over HTTP for the duration of `maxAge`. If your HTTPS configuration breaks, users won't be able to access your site until you fix it or the cached policy expires. Start with a short `maxAge` (like `1 day`) during testing, then increase it to `180 days` or longer once you're confident in your HTTPS setup. ::: ### HSTS configuration reference | Option | Type | Description | |--------|------|-------------| | `enabled` | `boolean` | Turn HSTS on or off. | | `maxAge` | `number` or `string` | How long browsers should remember the HTTPS-only policy. Accepts seconds as a number or a time expression like `'180 days'`. | | `includeSubDomains` | `boolean` | When `true`, applies the HTTPS-only policy to all subdomains. | ## X-Frame-Options (clickjacking protection) Clickjacking attacks embed your site in an invisible iframe on a malicious page. The attacker overlays deceptive content, tricking users into clicking buttons on your hidden site. A user might think they're clicking a "Play Video" button, but they're actually clicking "Delete Account" on your application. The X-Frame-Options header prevents your pages from being embedded in frames on other sites. ```ts title="config/shield.ts" import { defineConfig } from '@adonisjs/shield' const shieldConfig = defineConfig({ xFrame: { enabled: true, action: 'DENY', }, }) export default shieldConfig ``` The `DENY` action blocks all framing. If you need to embed your site in frames on your own domain (like for an admin panel preview), use `SAMEORIGIN` instead. ```ts title="config/shield.ts" import { defineConfig } from '@adonisjs/shield' const shieldConfig = defineConfig({ xFrame: { enabled: true, action: 'SAMEORIGIN', }, }) export default shieldConfig ``` To allow a specific external domain to frame your content, use `ALLOW-FROM` with the domain. ```ts title="config/shield.ts" import { defineConfig } from '@adonisjs/shield' const shieldConfig = defineConfig({ xFrame: { enabled: true, action: 'ALLOW-FROM', domain: 'https://trusted-partner.com', }, }) export default shieldConfig ``` :::tip If you've configured CSP, you can use the `frame-ancestors` directive instead of X-Frame-Options. The CSP directive offers more flexibility, including support for multiple domains. When using `frame-ancestors`, you can disable the `xFrame` guard to avoid redundant headers. ::: ### X-Frame configuration reference | Option | Type | Description | |--------|------|-------------| | `enabled` | `boolean` | Turn X-Frame protection on or off. | | `action` | `string` | The framing policy: `'DENY'`, `'SAMEORIGIN'`, or `'ALLOW-FROM'`. | | `domain` | `string` | Required when `action` is `'ALLOW-FROM'`. The domain allowed to frame your content. | ## Content-Type sniffing protection Browsers try to be helpful by guessing content types when servers don't specify them correctly. If your server accidentally serves a user-uploaded file with the wrong content type, browsers might "sniff" the content and execute it as a script. An attacker could upload a file that looks like an image but contains JavaScript, and the browser might execute it. The `X-Content-Type-Options: nosniff` header tells browsers to trust the `Content-Type` header and never guess. This prevents content-type confusion attacks. ```ts title="config/shield.ts" import { defineConfig } from '@adonisjs/shield' const shieldConfig = defineConfig({ contentTypeSniffing: { enabled: true, }, }) export default shieldConfig ``` This guard has no additional configuration options. When enabled, Shield adds the `X-Content-Type-Options: nosniff` header to all responses. ## See also - [Session configuration](../basics/session.md) for setting up the session package required by Shield - [Exception handling](../basics/exception_handling.md) for customizing error responses - [Vite integration](../frontend/vite.md) for configuring asset bundling with CSP --- # Rate limiting This guide covers rate limiting in AdonisJS applications. You will learn how to: - Install and configure the limiter package with Redis, database, or memory stores - Create throttle middleware for HTTP requests - Apply dynamic rate limits based on user authentication - Use rate limiting directly for login protection and job queues - Handle rate limit exceptions and customize error messages - Create custom storage providers ## Overview Rate limiting controls how many requests a user can make to your application within a given time period. When a user exceeds their limit, subsequent requests are rejected until the time window resets. You need rate limiting to protect your application from abuse. Without it, a single user (or bot) can overwhelm your server with requests, consuming resources meant for legitimate users. Rate limiting also helps prevent brute-force attacks on login forms, protects expensive API endpoints from overuse, and ensures fair access to shared resources. The `@adonisjs/limiter` package is built on top of [node-rate-limiter-flexible](https://github.com/animir/node-rate-limiter-flexible), which provides one of the fastest rate-limiting APIs and uses atomic increments to avoid race conditions. ## Installation Install and configure the package using the following command: ```sh node ace add @adonisjs/limiter ``` :::disclosure{title="See steps performed by the add command"} 1. Installs the `@adonisjs/limiter` package using the detected package manager. 2. Registers the following service provider inside the `adonisrc.ts` file. ```ts { providers: [ // ...other providers () => import('@adonisjs/limiter/limiter_provider') ] } ``` 3. Creates the `config/limiter.ts` file. 4. Creates the `start/limiter.ts` file for defining HTTP throttle middleware. 5. Defines the following environment variable and its validation inside the `start/env.ts` file. ```ts LIMITER_STORE=redis ``` 6. Optionally creates the database migration for the `rate_limits` table if using the `database` store. ::: ## Configuration The rate limiter configuration is stored in the `config/limiter.ts` file. You define which storage backends are available and which one to use by default. ```ts title="config/limiter.ts" import env from '#start/env' import { defineConfig, stores } from '@adonisjs/limiter' const limiterConfig = defineConfig({ /** * The default store is selected via environment variable, * allowing different stores in different environments. */ default: env.get('LIMITER_STORE'), stores: { redis: stores.redis({}), database: stores.database({ tableName: 'rate_limits' }), memory: stores.memory({}), }, }) export default limiterConfig declare module '@adonisjs/limiter/types' { export interface LimitersList extends InferLimiters {} } ``` The `default` property specifies which store to use for rate limiting. The `stores` object defines all available storage backends. We recommend always configuring the `memory` store so you can use it during testing. See also: [Rate limiter config stub](https://github.com/adonisjs/limiter/blob/2.x/stubs/config/limiter.stub) ### Environment variables The default store is controlled by the `LIMITER_STORE` environment variable, allowing you to switch stores between environments. For example, you might use `memory` during testing and `redis` in production. The environment variable must be validated in `start/env.ts` to ensure only configured stores are allowed: ```ts title="start/env.ts" { LIMITER_STORE: Env.schema.enum(['redis', 'database', 'memory'] as const), } ``` ### Shared options All storage backends accept the following options: ```ts title="config/limiter.ts" { duration: '1 minute', requests: 10, /** * After 12 requests, block the key in memory * and stop querying the database. */ inMemoryBlockOnConsumed: 12, inMemoryBlockDuration: '1 min' } ``` ::::options :::option{name="keyPrefix"} Prefix for keys in storage. The database store ignores this since separate tables provide isolation. ::: :::option{name="execEvenly"} Adds artificial delay to spread requests evenly across the time window. See [smooth out traffic peaks](https://github.com/animir/node-rate-limiter-flexible/wiki/Smooth-out-traffic-peaks) for details. ::: :::option{name="inMemoryBlockOnConsumed"} Number of requests after which to block the key in memory, reducing database queries from abusive users. ::: :::option{name="inMemoryBlockDuration"} How long to block keys in memory. Reduces database load by checking memory first. The `inMemoryBlockOnConsumed` option is useful when users continue making requests after exhausting their quota. Instead of querying the database for every rejected request, you can block them in memory: ::: :::: ### Redis store The Redis store requires the `@adonisjs/redis` package to be configured first. ```ts title="config/limiter.ts" { redis: stores.redis({ connectionName: 'main', rejectIfRedisNotReady: false, }), } ``` ::::options :::option{name="connectionName"} The Redis connection from `config/redis.ts`. We recommend using a separate database for the limiter. ::: :::option{name="rejectIfRedisNotReady"} When `true`, rejects rate-limiting requests if Redis connection status is not `ready`. ::: :::: ### Database store The database store requires the `@adonisjs/lucid` package to be configured first. :::warning The database store only supports MySQL, PostgreSQL, and SQLite. Other databases like MongoDB are not compatible and will throw an error at runtime. ::: ```ts title="config/limiter.ts" { database: stores.database({ connectionName: 'mysql', dbName: 'my_app', tableName: 'rate_limits', schemaName: 'public', clearExpiredByTimeout: false, }), } ``` :::options :::option{name="connectionName"} The database connection from `config/database.ts`. Uses the default connection if not specified. ::: :::option{name="dbName"} The database name for SQL queries. Inferred from connection config, but required when using a connection string. ::: :::option{name="tableName"} The table for storing rate limit data. ::: :::option{name="schemaName"} The schema for SQL queries (PostgreSQL only). ::: :::option{name="clearExpiredByTimeout"} When `true`, clears expired keys every 5 minutes. Only keys expired for more than 1 hour are removed. ::: ::: ## Throttling HTTP requests The most common use case is throttling HTTP requests with middleware. The `limiter.define` method creates reusable throttle middleware that you can apply to routes. Open the `start/limiter.ts` file to see the pre-defined global throttle middleware. This middleware allows users to make 10 requests per minute based on their IP address: ```ts title="start/limiter.ts" import limiter from '@adonisjs/limiter/services/main' export const throttle = limiter.define('global', () => { return limiter.allowRequests(10).every('1 minute') }) ``` Apply the middleware to any route: ```ts title="start/routes.ts" import router from '@adonisjs/core/services/router' import { throttle } from '#start/limiter' router .get('/', () => {}) .use(throttle) ``` When a user exceeds 10 requests within a minute, they receive a `429 Too Many Requests` response until the time window resets. ### Using a custom key By default, requests are rate-limited by the user's IP address. You can specify a different key using the `usingKey` method. This is useful when you want to limit by user ID, API key, or any other identifier: ```ts title="start/limiter.ts" export const throttle = limiter.define('global', (ctx) => { return limiter .allowRequests(10) .every('1 minute') .usingKey(`user_${ctx.auth.user.id}`) }) ``` ### Switching the backend store You can override the default store for specific middleware using the `store` method: ```ts title="start/limiter.ts" limiter .allowRequests(10) .every('1 minute') .store('redis') ``` ### Blocking abusive users The `blockFor` method extends the lockout period when users continue making requests after exhausting their quota. This discourages abuse more effectively than simply resetting the counter: ```ts title="start/limiter.ts" limiter .allowRequests(10) .every('1 minute') /** * If a user sends an 11th request within one minute, * block them for 30 minutes instead of just waiting * for the 1-minute window to reset. */ .blockFor('30 mins') ``` ## Dynamic rate limiting Different users often need different rate limits. Authenticated users might get higher limits than guests, or premium subscribers might get unlimited access while free users are restricted. The callback passed to `limiter.define` receives the HTTP context, allowing you to apply different limits based on request properties: ```ts title="start/limiter.ts" export const apiThrottle = limiter.define('api', (ctx) => { /** * Authenticated users get 100 requests per minute, * tracked by their user ID. */ if (ctx.auth.user) { return limiter .allowRequests(100) .every('1 minute') .usingKey(`user_${ctx.auth.user.id}`) } /** * Guest users get 10 requests per minute, * tracked by their IP address. */ return limiter .allowRequests(10) .every('1 minute') .usingKey(`ip_${ctx.request.ip()}`) }) ``` ```ts title="start/routes.ts" import { apiThrottle } from '#start/limiter' router .get('/api/repos/:id/stats', [RepoStatusController]) .use(apiThrottle) ``` ## Handling ThrottleException When a user exhausts their rate limit, the middleware throws the `E_TOO_MANY_REQUESTS` exception. The exception is automatically converted to an HTTP response using content negotiation: - Requests with `Accept: application/json` receive a JSON error object. - Requests with `Accept: application/vnd.api+json` receive a JSON API formatted error. - All other requests receive a plain text message. You can use [status pages](../basics/exception_handling.md#status-pages) to show a custom error page. See also: [E_TOO_MANY_REQUESTS exception reference](../../reference/exceptions.md#e_too_many_requests) The middleware will also add the following response headers: - `X-RateLimit-Limit`: Total number of requests that can be made - `X-RateLimit-Remaining` Number of requests remaining - `Retry-After`: Number of seconds after which the client can retry - `X-RateLimit-Reset`: Timestamp after which the client can retry ### Customizing the error response You can customize the error message without handling the exception globally using the `limitExceeded` hook: ```ts title="start/limiter.ts" export const throttle = limiter.define('global', () => { return limiter .allowRequests(10) .every('1 minute') .limitExceeded((error) => { error .setStatus(400) .setMessage('Cannot process request. Try again later') }) }) ``` ### Using translations If you have configured the [@adonisjs/i18n](../digging_deeper/i18n.md) package, define a translation using the `errors.E_TOO_MANY_REQUESTS` key: ```json title="resources/lang/fr/errors.json" { "E_TOO_MANY_REQUESTS": "Trop de demandes" } ``` You can also use a custom translation key with interpolated values: ```ts title="start/limiter.ts" limitExceeded((error) => { error.t('errors.rate_limited', { limit: error.response.limit, remaining: error.response.remaining, }) }) ``` ### Handling the exception globally For more control, handle the exception in your [global exception handler](../basics/exception_handling.md#handling-exceptions): ```ts title="app/exceptions/handler.ts" import { errors } from '@adonisjs/limiter' import { HttpContext, ExceptionHandler } from '@adonisjs/core/http' export default class HttpExceptionHandler extends ExceptionHandler { protected debug = !app.inProduction protected renderStatusPages = app.inProduction async handle(error: unknown, ctx: HttpContext) { if (error instanceof errors.E_TOO_MANY_REQUESTS) { const message = error.getResponseMessage(ctx) const headers = error.getDefaultHeaders() Object.keys(headers).forEach((header) => { ctx.response.header(header, headers[header]) }) return ctx.response.status(error.status).send(message) } return super.handle(error, ctx) } } ``` ## Direct usage Beyond HTTP middleware, you can use the limiter directly in any part of your application. This is useful for protecting login forms from brute-force attacks, limiting background job execution, or controlling access to expensive operations. ### Creating a limiter instance Use the `limiter.use` method to create a limiter instance with specific settings: ```ts title="app/services/reports_service.ts" import limiter from '@adonisjs/limiter/services/main' const reportsLimiter = limiter.use('redis', { requests: 1, duration: '1 hour' }) ``` | Option | Description | |--------|-------------| | `requests` | Number of requests allowed within the duration. | | `duration` | Time window in seconds or as a [time expression](../../reference/helpers.md#seconds) string. | | `blockDuration` | Optional. Duration to block the key after all requests are exhausted. | | `inMemoryBlockOnConsumed` | Optional. See [shared options](#shared-options). | | `inMemoryBlockDuration` | Optional. See [shared options](#shared-options). | To use the default store, omit the first parameter: ```ts const reportsLimiter = limiter.use({ requests: 1, duration: '1 hour' }) ``` ### Limiting expensive operations The `attempt` method executes a callback only if the rate limit hasn't been exceeded. It returns the callback's result, or `undefined` if the limit was reached: ```ts title="app/services/reports_service.ts" import limiter from '@adonisjs/limiter/services/main' const reportsLimiter = limiter.use({ requests: 1, duration: '1 hour' }) export async function generateUserReport(userId: number) { const key = `reports_user_${userId}` const executed = await reportsLimiter.attempt(key, async () => { await generateReport(userId) return true }) if (!executed) { const availableIn = await reportsLimiter.availableIn(key) throw new Error(`Too many requests. Try after ${availableIn} seconds`) } return 'Report generated' } ``` ### Preventing brute-force login attacks The `penalize` method is designed for scenarios where you want to consume a request only when an operation fails. This is perfect for login protection where you want to track failed attempts, not successful ones. ```ts title="app/controllers/session_controller.ts" import User from '#models/user' import { HttpContext } from '@adonisjs/core/http' import limiter from '@adonisjs/limiter/services/main' export default class SessionController { async store({ request, response, session }: HttpContext) { const { email, password } = request.only(['email', 'password']) /** * Create a limiter that allows 5 failed attempts per minute, * then blocks for 20 minutes. */ const loginLimiter = limiter.use({ requests: 5, duration: '1 min', blockDuration: '20 mins' }) /** * Use IP + email combination as the key. This ensures that if * an attacker is trying multiple emails, we block the attacker's * IP without affecting legitimate users trying to log in with * their own email from different IPs. */ const key = `login_${request.ip()}_${email}` /** * The penalize method consumes one request only if * the callback throws an error. */ const [error, user] = await loginLimiter.penalize(key, () => { return User.verifyCredentials(email, password) }) if (error) { session.flashAll() session.flashErrors({ E_TOO_MANY_REQUESTS: `Too many login attempts. Try again after ${error.response.availableIn} seconds` }) return response.redirect().back() } /** * Login successful - proceed with creating the session */ } } ``` ## Manual request consumption For fine-grained control, you can manually check and consume requests instead of using `attempt` or `penalize`. :::warning Calling `remaining` and `increment` separately creates a race condition where multiple concurrent requests might both pass the check before either increments the counter. Use the `consume` method instead, which performs an atomic check-and-increment. ::: The `consume` method increments the counter and throws an exception if the limit has been reached. You can optionally pass an **amount** as the second argument to consume multiple slots at once (useful for "weighted" rate limiting). ```ts title="app/services/api_service.ts" import { errors } from '@adonisjs/limiter' import limiter from '@adonisjs/limiter/services/main' const requestsLimiter = limiter.use({ requests: 10, duration: '1 minute' }) export async function handleApiRequest(userId: number) { const key = `api_user_${userId}` try { /** * Consume 5 slots at once for a heavy operation */ await requestsLimiter.consume(key, 5) return await performAction() } catch (error) { if (error instanceof errors.E_TOO_MANY_REQUESTS) { throw new Error('Rate limit exceeded') } throw error } } ``` ## Incrementing without throwing The `increment` method works like `consume` but does not throw an exception when the limit is exceeded, it also accepts an optional amount. Instead of throwing an exception, it returns the limiter response object, allowing you to check the remaining requests and decide how to handle the situation: ```ts title="app/services/api_service.ts" import limiter from '@adonisjs/limiter/services/main' const requestsLimiter = limiter.use({ requests: 10, duration: '1 minute' }) export async function handleApiRequest(userId: number) { const key = `api_user_${userId}` const response = await requestsLimiter.increment(key, 5) if (response.remainingPoints < 0) { throw new Error('Rate limit exceeded') } return await performAction() } ``` :::info Validation of the amount parameter The amount parameter must be a positive integer. To prevent logic errors or security bypasses, if you provide a value less than or equal to `0`, the limiter will automatically fallback to `1`. ::: ## Blocking keys You can extend the lockout period for users who continue making requests after exhausting their quota. This is more punitive than standard rate limiting and discourages abuse. Automatic blocking occurs when you create a limiter with the `blockDuration` option: ```ts title="app/services/api_service.ts" import limiter from '@adonisjs/limiter/services/main' const requestsLimiter = limiter.use({ requests: 10, duration: '1 minute', blockDuration: '30 mins' }) /** * A user can make 10 requests per minute. If they send * an 11th request, they're blocked for 30 minutes. * The consume, attempt, and penalize methods all * enforce this behavior automatically. */ await requestsLimiter.consume('a_unique_key') ``` You can also block a key manually: ```ts await requestsLimiter.block('a_unique_key', '30 mins') ``` ## Resetting attempts Sometimes you need to restore requests to a user. For example, if a background job completes, you might want to let the user queue another one. The decrement method reduces the request count. By default, it decrements by 1, but you can specify a custom amount as the second argument. ```ts title="app/jobs/process_report.ts" import limiter from '@adonisjs/limiter/services/main' const jobsLimiter = limiter.use({ requests: 10, duration: '5 mins', }) export async function processReportJob(userId: number) { const key = `jobs_user_${userId}` await jobsLimiter.attempt(key, async () => { await processJob() /** * Job completed - give the slot back so * another job can be queued. */ await jobsLimiter.decrement(key, 5) }) } ``` :::info Just like increment and consume, providing an amount `<= 0` will cause the method to fallback to `1`. ::: :::tip The `decrement` method is not atomic. Under high concurrency, the request count might briefly go to `-1`. Use the `delete` method if you need to completely reset a key. ::: The `delete` method removes a key entirely: ```ts await requestsLimiter.delete('unique_key') ``` ## Testing During testing, you typically want to use the `memory` store instead of Redis or a database. Set the environment variable in your `.env.test` file: ```dotenv title=".env.test" LIMITER_STORE=memory ``` Clear the rate-limiting storage between tests using the `limiter.clear` method: ```ts title="tests/functional/reports.spec.ts" import limiter from '@adonisjs/limiter/services/main' test.group('Reports', (group) => { group.each.setup(() => { return () => limiter.clear(['memory']) }) }) ``` You can also call `clear` without arguments to flush all configured stores: ```ts return () => limiter.clear() ``` :::warning When using Redis, the `clear` method flushes the entire database. Use a separate Redis database for the rate limiter to avoid clearing application data. Configure this in `config/redis.ts` by creating a dedicated connection. ::: ## Creating a custom storage provider You can create custom storage providers by implementing the `LimiterStoreContract` interface. This is useful when you need to use a database not supported by the built-in stores. ```ts title="app/limiter/mongodb_store.ts" import string from '@adonisjs/core/helpers/string' import { LimiterResponse } from '@adonisjs/limiter' import { LimiterStoreContract, LimiterConsumptionOptions } from '@adonisjs/limiter/types' export type MongoDbLimiterConfig = { client: MongoDBConnection } export class MongoDbLimiterStore implements LimiterStoreContract { readonly name = 'mongodb' declare readonly requests: number declare readonly duration: number declare readonly blockDuration: number constructor(config: MongoDbLimiterConfig & LimiterConsumptionOptions) { this.requests = config.requests this.duration = string.seconds.parse(config.duration) this.blockDuration = string.seconds.parse(config.blockDuration) } /** * Consume one request for the key. Throws an error * when all requests have been consumed. */ async consume(key: string | number, amount?: number): Promise {} /** * Consume one request without throwing when exhausted. */ async increment(key: string | number, amount?: number): Promise {} /** * Restore one request to the key. */ async decrement(key: string | number, amount?: number): Promise {} /** * Block a key for the specified duration. */ async block( key: string | number, duration: string | number ): Promise {} /** * Set the consumed request count for a key. */ async set( key: string | number, requests: number, duration?: string | number ): Promise {} /** * Delete a key from storage. */ async delete(key: string | number): Promise {} /** * Flush all keys from storage. */ async clear(): Promise {} /** * Get the limiter response for a key, or null if * the key doesn't exist. */ async get(key: string | number): Promise {} } ``` ### Creating the config helper Create a helper function to use your store in the config file. The helper should return a `LimiterManagerStoreFactory` function: ```ts title="app/limiter/mongodb_store.ts" import { LimiterManagerStoreFactory } from '@adonisjs/limiter/types' export function mongoDbStore(config: MongoDbLimiterConfig) { const storeFactory: LimiterManagerStoreFactory = (runtimeOptions) => { return new MongoDbLimiterStore({ ...config, ...runtimeOptions }) } return storeFactory } ``` ### Using your custom store ```ts title="config/limiter.ts" import env from '#start/env' import { mongoDbStore } from '#app/limiter/mongodb_store' import { defineConfig } from '@adonisjs/limiter' const limiterConfig = defineConfig({ default: env.get('LIMITER_STORE'), stores: { mongodb: mongoDbStore({ client: mongoDb }) }, }) ``` ### Wrapping rate-limiter-flexible drivers If you're wrapping an existing driver from [node-rate-limiter-flexible](https://github.com/animir/node-rate-limiter-flexible), use the `RateLimiterBridge` class for simpler implementation: ```ts title="app/limiter/mongodb_store.ts" import { RateLimiterBridge } from '@adonisjs/limiter' import { RateLimiterMongo } from 'rate-limiter-flexible' export class MongoDbLimiterStore extends RateLimiterBridge { readonly name = 'mongodb' constructor(config: MongoDbLimiterConfig & LimiterConsumptionOptions) { super( new RateLimiterMongo({ storeClient: config.client, points: config.requests, duration: string.seconds.parse(config.duration), blockDuration: string.seconds.parse(config.blockDuration) }) ) } /** * The bridge handles most methods, but you must * implement clear() yourself. */ async clear() { await this.config.client.collection('rate_limits').deleteMany({}) } } ``` --- # Application Lifecycle This guide covers the application lifecycle in AdonisJS. You will learn: - The three lifecycle phases (boot, start, and termination) - When each phase executes and what happens during it - How to hook into phases using service providers and preload files ## Overview The application lifecycle in AdonisJS consists of three distinct phases: **boot**, **start**, and **termination**. Each phase serves a specific purpose in preparing your application, running it, and gracefully shutting it down. Understanding the lifecycle is essential when you need to execute code at specific points during your application's runtime. For example, you might want to register custom validation rules before your application starts handling requests, or perform cleanup operations before your application terminates. The lifecycle flows chronologically from boot to start, and eventually to termination when the process receives a shutdown signal. Each phase has clearly defined responsibilities and happens in a predictable order, allowing you to hook into the exact moment you need. ## Boot phase The boot phase is the initial stage where AdonisJS prepares your application for execution. During this phase, you can use the IoC container to fetch bindings and extend parts of the framework. Service providers register their bindings into the container and execute their `boot` methods. The framework itself is being configured, but your application isn't yet ready to handle requests or execute commands. The boot phase completes before any preload files are imported or application-specific code runs. Think of it as the foundation-laying phase where the framework assembles all the pieces it needs. :::media ![Boot phase flow chart](./boot_phase.png) ::: ## Start phase The start phase is where your application comes to life. During this phase, AdonisJS imports preload files and executes the `start` and `ready` methods from service providers. Application-specific initialization happens here. Routes are registered, event listeners are attached, and setup code runs. By the end of this phase, your application is fully operational and ready to handle HTTP requests, execute Ace commands, or run tests depending on the environment. :::media ![Start phase flow chart](./start_phase.png) ::: The start phase is environment-aware, meaning you can configure different behavior for the HTTP server, Ace commands, or test environments. All preload files configured for the current environment are imported in parallel for optimal performance. ## Termination phase The termination phase happens when AdonisJS begins graceful shutdown. This usually occurs when the process receives the `SIGTERM` signal, such as when you stop your development server or during a deployment. During this phase, service providers execute their `shutdown` methods, allowing them to perform cleanup operations like closing database connections, flushing logs, or canceling pending background jobs. :::media ![Termination phase flow chart](./termination_phase.png) ::: Graceful shutdown ensures your application stops cleanly rather than abruptly terminating mid-operation, helping prevent data corruption. ## Hooking into lifecycle phases You can hook into different phases of the application lifecycle using service providers and preload files. Service providers offer lifecycle methods (`boot`, `start`, `ready`, and `shutdown`) that execute at specific points, while preload files run during the start phase. ### Hooking into the boot phase Use the `boot` method in a service provider to execute code during the boot phase. This is where you should extend the framework or configure services that other parts of your application depend on. The following example extends VineJS with a custom phoneNumber validation rule. This rule will be available throughout your application. ```ts title="providers/app_provider.ts" import { VineString } from '@vinejs/vine' import type { ApplicationService } from '@adonisjs/core/types' export default class AppProvider { constructor(protected app: ApplicationService) {} async boot() { VineString.macro('phoneNumber', function (this: VineString) { return this.use((value, field) => { if (typeof value !== 'string') { return } if (!/^\d{10}$/.test(value)) { field.report('The {{ field }} must be a valid 10-digit phone number', field) } }) }) } } ``` ### Hooking into the start phase You can hook into the start phase using either service provider methods or preload files. Service providers offer `start` and `ready` methods, while preload files provide a simpler approach for application-specific initialization. #### Using service provider methods The `start` method executes after the boot phase completes but before the application is ready. The `ready` method executes once the application is fully started and ready to handle requests or commands. ```ts title="providers/app_provider.ts" import type { ApplicationService } from '@adonisjs/core/types' export default class AppProvider { constructor(protected app: ApplicationService) {} async start() { const database = await this.app.container.make('lucid.db') /** * Verify database connection is working */ await database.connection().select(1) } async ready() { if (this.app.getEnvironment() === 'web') { const logger = await this.app.container.make('logger') logger.info('HTTP server is ready to accept requests') } } } ``` #### Using preload files Preload files offer a simpler way to run code during the start phase without creating a service provider. They're ideal for application-specific initialization like registering routes, attaching event listeners, or configuring middleware. Create a preload file using the `make:preload` command. ```sh node ace make:preload events ``` This command creates a new file in the `start` directory and automatically registers it in your `adonisrc.ts` configuration file. ```ts title="start/events.ts" import emitter from '@adonisjs/core/services/emitter' import logger from '@adonisjs/core/services/logger' emitter.on('user:registered', function (user) { logger.info({ userId: user.id }, 'New user registered') }) emitter.on('order:placed', function (order) { logger.info({ orderId: order.id }, 'New order placed') }) ``` You can configure preload files to load only in specific runtime environments. ```ts title="adonisrc.ts" { preloads: [ () => import('#start/routes'), () => import('#start/kernel'), { file: () => import('#start/events'), environment: ['web', 'console'] } ] } ``` The `environment` property accepts an array of values: `web` (HTTP server), `console` (Ace commands), `test` (test runner), and `repl` (REPL environment). ### Hooking into the termination phase Use the `shutdown` method in a service provider to execute cleanup operations during graceful shutdown. This ensures resources are properly released before your application terminates. ```ts title="providers/app_provider.ts" import type { ApplicationService } from '@adonisjs/core/types' export default class AppProvider { constructor(protected app: ApplicationService) {} async shutdown() { const redis = await this.app.container.make('redis') /** * Close all Redis connections */ await redis.quit() const logger = await this.app.container.make('logger') logger.info('Application shutdown complete') } } ``` ## See also - [Service providers guide](link-to-service-providers) for learning how to create and register service providers, and understand the complete lifecycle of the `register`, `boot`, `start`, `ready`, and `shutdown` methods - [AdonisRC file reference](link-to-adonisrc-reference) for complete reference on configuring preload files and other application settings --- # Dependency injection and the IoC container This guide covers dependency injection and the IoC container in AdonisJS. You will learn: - How to use the `@inject` decorator for automatic dependency resolution - The difference between constructor and method injection - When and how to use the IoC container manually - How to register bindings and singletons for complex dependencies - How to implement the adapter pattern using abstract classes - How to swap dependencies during testing ## Overview Dependency injection is a design pattern that eliminates the need to manually create and manage class dependencies. Instead of creating dependencies inside a class, you declare them as constructor parameters or method parameters, and the IoC container resolves them automatically. AdonisJS includes a powerful IoC (Inversion of Control) container that handles dependency injection throughout your application. When you type-hint a class as a dependency, the container automatically creates an instance of that class and injects it where needed. The IoC container is already integrated into core parts of AdonisJS including [controllers](../basics/controllers.md), [middleware](../basics/middleware.md), [event listeners](../digging_deeper/emitter.md), and [Ace commands](../ace/introduction.md). This means you can type-hint dependencies in these classes and they'll be resolved automatically when the framework constructs them. :::note AdonisJS uses TypeScript's `experimentalDecorators` and `emitDecoratorMetadata` compiler options to enable dependency injection. These are pre-configured in the `tsconfig.json` file of new AdonisJS projects. ::: ## Your first dependency injection Let's start with a practical example. We'll create an `AvatarService` that generates Gravatar URLs for users, then inject it into a controller. ::::steps :::step{title="Create the service"} First, create a service class that will be injected. This service generates Gravatar avatar URLs based on user email addresses. ```ts title="app/services/avatar_service.ts" import User from '#models/user' import { createHash } from 'node:crypto' export class AvatarService { protected getGravatarAvatar(user: User) { const emailHash = createHash('md5').update(user.email).digest('hex') const url = new URL(emailHash, 'https://gravatar.com/avatar/') url.searchParams.set('size', '200') return url.toString() } getAvatarFor(user: User) { return this.getGravatarAvatar(user) } } ``` ::: :::step{title="Inject the service into a controller"} Next, create a controller that uses the `AvatarService`. The `@inject()` decorator tells the container to automatically resolve and inject the service. ```ts title="app/controllers/users_controller.ts" import { inject } from '@adonisjs/core' import type { HttpContext } from '@adonisjs/core/http' import { AvatarService } from '#services/avatar_service' import User from '#models/user' // [!code highlight] @inject() export default class UsersController { /** * The AvatarService is automatically injected by the container * when this controller is constructed */ // [!code highlight] constructor(protected avatarService: AvatarService) {} async store({ request }: HttpContext) { /** * Create a new user (simplified for demonstration) */ const user = await User.create(request.only(['email', 'username'])) /** * Use the injected service to generate and save the avatar URL */ // [!code highlight] const avatarUrl = this.avatarService.getAvatarFor(user) user.avatarUrl = avatarUrl await user.save() return user } } ``` ::: :::step{title="Register the route"} Finally, connect your controller to a route. When you visit this endpoint, AdonisJS automatically constructs the controller using the container. ```ts title="start/routes.ts" import router from '@adonisjs/core/services/router' import { controllers } from '#generated/controllers' router.post('/users', [controllers.Users, 'store']) ``` The `@inject()` decorator is required on the controller class. Without it, the container won't know to resolve dependencies. The decorator uses TypeScript's reflection capabilities to detect constructor dependencies at runtime. ::: :::: ## Method injection Method injection works similarly to constructor injection, but instead of resolving dependencies for the entire class, the container resolves dependencies for a specific method. This is useful when only one method needs a particular dependency, or when you want to keep the class constructor simple. The `@inject()` decorator must be placed before the method when using method injection. ```ts title="app/controllers/users_controller.ts" import { inject } from '@adonisjs/core' import { HttpContext } from '@adonisjs/core/http' import { AvatarService } from '#services/avatar_service' import User from '#models/user' export default class UsersController { /** * The @inject decorator on the method tells the container * to resolve the avatarService parameter automatically */ // [!code highlight:2] @inject() async store({ request }: HttpContext, avatarService: AvatarService) { const user = await User.create(request.only(['email', 'username'])) /** * Use the injected service directly as a method parameter */ // [!code highlight] const avatarUrl = avatarService.getAvatarFor(user) user.avatarUrl = avatarUrl await user.save() return user } } ``` Notice that `HttpContext` is always the first parameter for controller methods, followed by any dependencies you want to inject. The container automatically distinguishes between the HTTP context and injectable dependencies. ## What can be injected? You can type-hint and inject only classes inside other classes. Since TypeScript types and interfaces are removed at compile time and are not visible to the runtime code, there is no way for the container to resolve them. If a class has other dependencies like configuration objects that cannot be auto-resolved, you must register the class as a binding within the container. We'll cover bindings [later in this guide](#bindings). ### The import type pitfall A common issue that causes dependency injection to fail silently is when classes are accidentally imported using TypeScript's `import type` syntax. This happens frequently because code editors with auto-import features often default to importing classes as types. When you use `import type`, TypeScript strips the import entirely during compilation. The container has no class constructor to resolve at runtime, so your dependency becomes `undefined`. ```ts title="❌ Wrong: Imported as a type" import { inject } from '@adonisjs/core' // [!code highlight] import type { AvatarService } from '#services/avatar_service' @inject() export default class UsersController { constructor(protected avatarService: AvatarService) {} } ``` ```ts title="✅ Correct: Imported as a value" import { inject } from '@adonisjs/core' // [!code highlight] import { AvatarService } from '#services/avatar_service' @inject() export default class UsersController { constructor(protected avatarService: AvatarService) {} } ``` ## Which classes support dependency injection The following classes are automatically constructed by the container, allowing you to use constructor injection and, in some cases, method injection. | Class Type | Constructor Injection | Method Injection | |---|---|---| | Controllers | ✅ | ✅ | | Middleware | ✅ | ❌ | | Event listeners | ✅ | ✅ (only `handle` method) | | Bouncer policies | ✅ | ❌ | | Transformers | ❌ | ✅ | | Ace commands | ❌ | ✅ (only lifecycle methods) | For any other classes you create, you'll need to use the container manually to construct them, which we'll cover in the next section. ## Using the container manually While AdonisJS automatically constructs controllers, middleware, and other framework classes using the container, you may need to manually construct your own classes in certain scenarios. For example, if you're implementing a queue system and want each job class to benefit from dependency injection, you'll need to use the container's API directly. ### Constructing classes with container.make The `container.make` method accepts a class constructor and returns an instance of it, automatically resolving all constructor dependencies marked with `@inject()`. Let's demonstrate this with a queue job scenario. We'll create two services: - A `LoggerService` for logging and a `UserService` that depends on it. - Then we'll use the container manually within a queue job to construct the `UserService`, which will automatically resolve its `LoggerService` dependency. The container instance is available throughout your AdonisJS application via the `app` service. ::::tabs :::tab{title="Services"} ```ts import { inject } from '@adonisjs/core' class LoggerService { log(message: string) { console.log(message) } } @inject() export class UserService { constructor(protected logger: LoggerService) {} createUser(data: any) { this.logger.log('Creating user...') } } ``` ::: :::tab{title="Queue job"} ```ts import app from '@adonisjs/core/services/app' import { UserService } from '#services/user_service' export default class ProcessUserJob { async handle(userData: any) { /** * Manually construct UserService using the container. * Its LoggerService dependency is automatically resolved. */ const userService = await app.container.make(UserService) await userService.createUser(userData) } } ``` ::: :::: The container recursively resolves the entire dependency tree. If `UserService` had dependencies, and those dependencies had their own dependencies, the container would resolve them all automatically. ### Calling methods with container.call You can perform method injection on any class method using the `container.call` method. This is useful when you want dependencies injected into a specific method rather than the entire class. The `container.call` method accepts the class instance, the method name, and an array of runtime values. Runtime values are passed as the initial parameters, followed by any auto-resolved dependencies. ::::tabs :::tab{title="Services"} ```ts import { inject } from '@adonisjs/core' class EmailService { send(to: string, message: string) { console.log(`Sending email to ${to}`) } } export class NotificationService { @inject() notify(userId: string, message: string, emailService: EmailService) { emailService.send(`user-${userId}@example.com`, message) } } ``` ::: :::tab{title="Usage"} ```ts import app from '@adonisjs/core/services/app' import { NotificationService } from '#services/notification_service' /** * Create the service instance */ const notificationService = await app.container.make(NotificationService) /** * Call the method using the container. * * The EmailService is automatically resolved and injected. * The first two arguments are runtime values we provide. */ await app.container.call( notificationService, 'notify', ['user-123', 'Welcome to our platform!'] ) ``` ::: :::: ## Bindings Bindings are the mechanism you use when classes require dependencies that cannot be auto-resolved with the `@inject()` decorator. For example, when a class needs a configuration object or a primitive value alongside its class dependencies. When a binding exists for a class, the container disables its auto-resolution logic and uses your factory function to create instances instead. ### Creating a binding Bindings must be registered in the `register` method of a [Service Provider](./service_providers.md). You can create a new provider using the `node ace make:provider` command. ::::steps :::step{title="Define the class that needs custom construction"} Let's create a `Cache` class that requires both a `RedisConnection` and a configuration object. Since we're registering this class as a binding, there's no need to use the `@inject` decorator. The container will use our factory function instead. ```ts title="app/services/cache.ts" import type { RedisConnection } from '@adonisjs/redis' export type CacheConfig = { ttl: string | number grace: boolean } export class Cache { constructor( public store: RedisConnection, public config: CacheConfig ) {} async get(key: string) { // Cache implementation } async set(key: string, value: any) { // Cache implementation } } ``` ::: :::step{title="Register the binding in a provider"} Create a provider and register the binding in its `register` method. The factory function receives a resolver that can create instances of other classes. ```ts title="providers/cache_provider.ts" import type { ApplicationService } from '@adonisjs/core/types' import redis from '@adonisjs/redis/services/main' import { Cache } from '#services/cache' export default class CacheProvider { constructor(protected app: ApplicationService) {} register() { this.app.container.bind(Cache, async (resolver) => { /** * Resolve redis dependency from the container */ const redis = await resolver.make('redis') const store = redis.connection() /** * Get configuration from the app config */ const config = this.app.config.get('cache') /** * Manually construct and return the Cache instance * with all its dependencies */ return new Cache(store, config) }) } } ``` ::: :::step{title="Use the binding"} The container uses your factory function from the binding instead of trying to auto-resolve dependencies. ```ts import app from '@adonisjs/core/services/app' import { Cache } from '#services/cache' const cache = await app.container.make(Cache) await cache.set('user:1', { name: 'Virk' }) const user = await cache.get('user:1') ``` ::: :::: Bindings give you complete control over how a class is constructed. The factory function receives a resolver that you can use to create other dependencies, allowing you to build complex dependency trees with custom logic. ### Singletons Singletons are bindings that are constructed only once and then cached. Multiple calls to `container.make` for a singleton will return the same instance. This is useful for services that should be shared across your application, like database connections or caching layers. ```ts title="providers/cache_provider.ts" import type { ApplicationService } from '@adonisjs/core/types' import redis from '@adonisjs/redis/services/main' import { Cache } from '#services/cache' export default class CacheProvider { constructor(protected app: ApplicationService) {} register() { /** * Use singleton instead of bind. * The Cache instance will be created once and reused. */ this.app.container.singleton(Cache, async (resolver) => { const redis = await resolver.make('redis') const store = redis.connection() const config = this.app.config.get('cache') return new Cache(store, config) }) } } ``` Now every call to `app.container.make(Cache)` returns the exact same `Cache` instance, making it efficient and ensuring shared state when needed. ### Aliases Aliases provide alternate string-based names for bindings, allowing you to request dependencies using descriptive names instead of class constructors. To create an alias, register it using `container.alias()` and update the `ContainerBindings` interface using TypeScript module augmentation for type safety. ```ts title="providers/cache_provider.ts" import type { ApplicationService } from '@adonisjs/core/types' import redis from '@adonisjs/redis/services/main' import { Cache } from '#services/cache' /** * Declare the alias type for TypeScript */ declare module '@adonisjs/core/types' { interface ContainerBindings { cache: Cache } } export default class CacheProvider { constructor(protected app: ApplicationService) {} register() { /** * Register the Cache binding as a singleton */ this.app.container.singleton(Cache, async (resolver) => { const store = redis.connection() const config = this.app.config.get('cache') return new Cache(store, config) }) /** * Create an alias so we can reference Cache by the string 'cache' */ this.app.container.alias('cache', Cache) } } ``` Now you can request the cache using the string alias. TypeScript will know that `app.container.make('cache')` returns a `Cache` instance, giving you full autocomplete and type checking. ```ts import app from '@adonisjs/core/services/app' /** * Request the cache using the string alias instead of the class */ const cache = await app.container.make('cache') ``` ### Binding existing values Sometimes you already have an instance and want to register it directly with the container rather than providing a factory function. The `bindValue` method binds an existing value to a class constructor or alias, and the container returns that exact value whenever it's requested. ```ts title="providers/app_provider.ts" import type { ApplicationService } from '@adonisjs/core/types' import { Config } from '#services/config' export default class AppProvider { constructor(protected app: ApplicationService) {} register() { /** * Create the instance ourselves with specific configuration */ const config = new Config({ environment: 'production', debug: false, }) /** * Bind the existing instance directly. * Every request for Config returns this exact object. */ this.app.container.bindValue(Config, config) } } ``` Unlike `bind` or `singleton` which accept factory functions, `bindValue` accepts the instance itself. This is useful when you need to register objects that were created outside the container, such as configuration objects parsed at startup or instances received from external libraries. The `bindValue` method is also available on the request-scoped resolver, which is how request-specific instances like `HttpContext` and `Logger` are registered during HTTP requests. See [Dependency injection during HTTP requests](#dependency-injection-during-http-requests) for an example. ## Dependency injection during HTTP requests The `HttpContext` object receives an isolated container resolver for each HTTP request. This allows you to register singleton instances that exist only for the duration of that specific request, which is useful for request-scoped services like loggers or user sessions. These request-scoped bindings should be registered in the `app/middleware/container_bindings_middleware.ts` file, which is pre-created and automatically registered in new AdonisJS applications. ```ts title="app/middleware/container_bindings_middleware.ts" import { HttpContext } from '@adonisjs/core/http' import type { NextFn } from '@adonisjs/core/types/http' import { Logger } from '@adonisjs/core/logger' export default class ContainerBindingsMiddleware { handle(ctx: HttpContext, next: NextFn) { /** * Register the HttpContext itself as a binding. * Any class that type-hints HttpContext will receive * this exact context instance for the current request. */ ctx.containerResolver.bindValue(HttpContext, ctx) /** * Register the request-specific logger. * All classes resolved during this request that depend * on Logger will receive this logger instance. */ ctx.containerResolver.bindValue(Logger, ctx.logger) return next() } } ``` Throughout the current HTTP request, any classes that type-hint `HttpContext` or `Logger` as dependencies will receive the exact instances registered here. This ensures consistency and proper scoping for request-specific data. ## Abstract classes as interfaces Since TypeScript interfaces are removed at runtime, you cannot use them for type-hinting dependencies. However, you can use abstract classes to achieve the same polymorphic behavior. This enables you to implement the Adapter design pattern, where multiple implementations conform to a common contract. Let's build a payment system that can work with different payment providers while keeping your business logic provider-agnostic. ### Defining the contract First, create an abstract class that defines the contract all payment providers must implement. This acts as your interface but remains available at runtime for the container to use as an injection token. ```ts title="app/services/payment_service.ts" /** * Abstract class acts as the interface. * Different payment providers will implement this. */ export default abstract class PaymentService { abstract charge(amount: number): Promise abstract refund(amount: number): Promise } ``` ### Creating concrete implementations Now create concrete implementations for different payment providers. Each provider implements the abstract class's methods with their specific logic, but they all conform to the same contract. :::codegroup ```ts title="app/services/stripe_provider.ts" import PaymentService from './payment_service.js' export default class StripeProvider implements PaymentService { async charge(amount: number) { console.log(`Charging ${amount} via Stripe`) } async refund(amount: number) { console.log(`Refunding ${amount} via Stripe`) } } ``` ```ts title="app/services/paypal_provider.ts" import PaymentService from './payment_service.js' export default class PaypalProvider implements PaymentService { async charge(amount: number) { console.log(`Charging ${amount} via PayPal`) } async refund(amount: number) { console.log(`Refunding ${amount} via PayPal`) } } ``` ::: Notice that both implementations follow the exact same contract. Your application code can depend on `PaymentService` without knowing which specific provider is being used. ### Configuring which implementation to use Register a binding that tells the container which concrete implementation to inject when someone requests the abstract `PaymentService`. This is where you decide whether your application uses Stripe, PayPal, or any other provider. ```ts title="providers/app_provider.ts" import type { ApplicationService } from '@adonisjs/core/types' import PaymentService from '#services/payment_service' import StripeProvider from '#services/stripe_provider' export default class AppProvider { constructor(protected app: ApplicationService) {} register() { /** * Bind the abstract PaymentService to the concrete StripeProvider. * Any class that type-hints PaymentService will receive StripeProvider. */ this.app.container.bind(PaymentService, () => { return new StripeProvider() }) } } ``` ### Using the abstraction in your code Now your business logic can depend on the abstract `PaymentService` without being coupled to any specific provider. ```ts title="app/controllers/checkout_controller.ts" import { inject } from '@adonisjs/core' import { HttpContext } from '@adonisjs/core/http' import PaymentService from '#services/payment_service' export default class CheckoutController { /** * Type-hint the abstract PaymentService. * The container injects StripeProvider (based on our binding). */ @inject() async store({ request }: HttpContext, paymentService: PaymentService) { const amount = request.input('amount') /** * We call methods on PaymentService, but the actual * implementation is StripeProvider. Our code doesn't * know or care which provider is used. */ await paymentService.charge(amount) return { success: true } } } ``` The power of this pattern is in its flexibility. To switch from Stripe to PayPal, you only need to change the binding in `AppProvider` from `new StripeProvider()` to `new PaypalProvider()`. Your controller and all other business logic remains completely unchanged. This separation makes your code more maintainable and testable. ## Contextual dependencies Contextual dependencies allow you to inject different implementations of the same abstract class based on which class is requesting it. This is useful when different parts of your application need different configurations or implementations of the same service. Building on the `PaymentService` example from the previous section, imagine you have different business requirements for different parts of your application. User subscriptions should process through Stripe (for recurring billing features), while one-time product purchases should go through PayPal (for broader payment method support). ### Setting up services with different needs Let's create two services that both need a `PaymentService`, but they should use different payment providers based on their specific business requirements. :::codegroup ```ts title="app/services/subscription_service.ts" import { inject } from '@adonisjs/core' import PaymentService from '#services/payment_service' @inject() export default class SubscriptionService { /** * SubscriptionService will receive a PaymentService instance * configured for Stripe (for recurring billing) */ constructor(protected paymentService: PaymentService) {} async createSubscription(userId: number, plan: string) { await this.paymentService.charge(999) } } ``` ```ts title="app/services/order_service.ts" import { inject } from '@adonisjs/core' import PaymentService from '#services/payment_service' @inject() export default class OrderService { /** * OrderService will receive a PaymentService instance * configured for PayPal (for one-time purchases) */ constructor(protected paymentService: PaymentService) {} async createOrder(userId: number, items: any[]) { await this.paymentService.charge(2499) } } ``` ::: Both services type-hint `PaymentService`, but they need different implementations. Without contextual dependencies, you could only bind one provider globally, forcing both services to use the same implementation. ### Registering contextual bindings Register contextual dependencies in a Service Provider using the `when().asksFor().provide()` API. This tells the container when a specific class asks for a dependency, provide a particular implementation. ```ts title="providers/app_provider.ts" import type { ApplicationService } from '@adonisjs/core/types' import PaymentService from '#services/payment_service' import StripeProvider from '#services/stripe_provider' import PaypalProvider from '#services/paypal_provider' import SubscriptionService from '#services/subscription_service' import OrderService from '#services/order_service' export default class AppProvider { constructor(protected app: ApplicationService) {} register() { /** * When SubscriptionService asks for PaymentService, * provide the Stripe implementation (for recurring billing) */ this.app.container .when(SubscriptionService) .asksFor(PaymentService) .provide(() => { return new StripeProvider() }) /** * When OrderService asks for PaymentService, * provide the PayPal implementation (for one-time purchases) */ this.app.container .when(OrderService) .asksFor(PaymentService) .provide(() => { return new PaypalProvider() }) } } ``` Now `SubscriptionService` automatically receives `StripeProvider` when it's constructed, while `OrderService` receives `PaypalProvider`, all through the same `PaymentService` type hint. This keeps your service code clean and focused on business logic while giving you fine-grained control over which dependencies are injected based on context. The contextual binding pattern is particularly powerful when combined with the adapter pattern from the previous section. Each service remains completely agnostic about which payment provider it's using, yet each gets exactly the implementation it needs for its specific use case. ## Resolution priority When the container resolves a dependency, it checks multiple sources in a specific order. Understanding this priority helps you predict which implementation will be injected and debug unexpected behavior. The container resolves dependencies in this order, stopping at the first match: | Priority | Source | Description | |----------|--------|-------------| | 1 | Swaps | Test overrides registered with `container.swap()` | | 2 | Contextual bindings | Bindings registered with `when().asksFor().provide()` | | 3 | Resolver values | Values bound to a scoped resolver (e.g., request-scoped bindings) | | 4 | Container values | Values registered with `container.bindValue()` | | 5 | Container bindings | Factory functions registered with `container.bind()` or `container.singleton()` | | 6 | Auto-construction | The container constructs the class itself using `@inject()` | Swaps have the highest priority because they're designed for testing. When you swap a dependency, you want the fake implementation to be used regardless of any other bindings. This ensures your tests remain isolated and predictable. Contextual bindings come next because they represent intentional, context-specific overrides. When you explicitly say "when ClassA asks for ServiceB, provide this specific implementation," that intent should override general bindings. If no swap or contextual binding matches, the container checks for values bound to the current resolver scope. During HTTP requests, the `HttpContext` and request-scoped `Logger` are bound this way, ensuring each request gets its own instances. Container-level values and bindings are checked next. These are your application-wide singletons and factories registered in service providers. Finally, if no binding exists at all, the container attempts auto-construction. It uses TypeScript's reflection metadata to identify constructor dependencies marked with `@inject()` and recursively resolves them. This priority order means you can layer your dependency configuration: define general bindings in providers, override them contextually for specific classes, and swap them entirely during tests—all without modifying the original code. ## Swapping dependencies during testing The container provides a straightforward API for swapping dependencies with fake implementations during tests. This allows you to test your code in isolation without hitting real external services. The `container.swap` method replaces a binding with a temporary implementation, and `container.restore` reverts it back to the original. During the swap, any part of your codebase that type-hints the swapped class will receive the fake implementation instead. ```ts title="tests/functional/users/list.spec.ts" import { test } from '@japa/runner' import app from '@adonisjs/core/services/app' import UserService from '#services/user_service' test('get all users', async ({ client, cleanup }) => { /** * Create a fake implementation that extends the real service */ class FakeUserService extends UserService { /** * Override the all() method to return fake data * instead of querying the database */ all() { return [ { id: 1, username: 'virk', email: 'virk@adonisjs.com' }, { id: 2, username: 'romain', email: 'romain@adonisjs.com' } ] } } /** * Swap UserService with our fake implementation. * Any code that resolves UserService during this test * will receive FakeUserService instead. */ app.container.swap(UserService, () => { return new FakeUserService() }) /** * Restore the original binding after the test completes. * The cleanup hook ensures this runs even if the test fails. */ cleanup(() => app.container.restore(UserService)) /** * Make the HTTP request. The controller will receive * FakeUserService, which returns our fake data. */ const response = await client.get('/users') response.assertStatus(200) response.assertBodyContains({ users: [ { username: 'virk' }, { username: 'romain' } ] }) }) ``` Swapping is particularly valuable when testing code that depends on external APIs, payment gateways, email services, or any other resource you don't want to interact with during automated tests. The fake implementation can simulate various scenarios (success, failure, edge cases) without requiring real infrastructure. ## Container events The container emits events when it resolves bindings, allowing you to observe and react to dependency resolution. This can be useful for debugging, monitoring, or implementing cross-cutting concerns. The container emits a single event type: `container_binding:resolved`. This event is triggered every time the container successfully resolves a class instance, whether through auto-resolution, bindings, or singletons. You can listen to this event using the application's event emitter. ```ts title="start/events.ts" import emitter from '@adonisjs/core/services/emitter' emitter.on('container_binding:resolved', (event) => { /** * event.binding contains the class constructor or string alias * that was resolved */ console.log('Resolved binding:', event.binding) /** * event.value contains the actual instance that was created */ console.log('Instance:', event.value) }) ``` The event object provides two properties. First, `event.binding` contains the binding key (either a class constructor or string alias) that was resolved. Second, `event.value` contains the actual instance that the container created and returned. This event is fired for every resolution, including nested dependencies. For example, if the container resolves `UserService`, which depends on `LoggerService`, you'll receive two events: one for `LoggerService` and one for `UserService`. ## See also - [Service Providers](./service_providers.md) - Learn how to register bindings and organize application bootstrapping - [The IoC Container README](https://github.com/adonisjs/fold/blob/develop/README.md) - Comprehensive API documentation in a framework-agnostic context - [Why Do You Need an IoC Container?](https://github.com/thetutlage/meta/discussions/4) - The framework creator's reasoning for using dependency injection - [TypeScript Decorators](https://www.typescriptlang.org/docs/handbook/decorators.html) - Understanding the decorator syntax used by `@inject()` - [Container API docs](https://api.adonisjs.com/modules/_adonisjs_fold.index) - To get a full view of available classes, methods and properties. --- # Service Providers This guide covers service providers in AdonisJS applications. You will learn how to: - Use lifecycle hooks to execute code at specific points during application startup and shutdown - Create custom service providers - Register bindings into the IoC container ## Overview Service providers are JavaScript classes with lifecycle hooks that execute at specific points during application startup and shutdown. This allows you to register bindings to the IoC container, extend framework classes using Macros, perform initialization at precise moments, and clean up resources during graceful shutdown. The key advantage is centralized initialization logic that runs at predictable times, without modifying core framework code or scattering setup code throughout your application. Every AdonisJS application and package uses service providers to hook into the application lifecycle, making them fundamental to understanding the framework. ## Understanding service providers Before creating your own service providers, it's helpful to understand how they work within an AdonisJS application. ### Where service providers are registered Service providers are registered in the `adonisrc.ts` file at the root of your project. This file defines which providers should load and in which runtime environments they should execute. ```ts title="adonisrc.ts" import { defineConfig } from '@adonisjs/core/app' export default defineConfig({ providers: [ () => import('@adonisjs/core/providers/app_provider'), () => import('@adonisjs/core/providers/hash_provider'), { file: () => import('@adonisjs/core/providers/repl_provider'), environment: ['repl', 'test'], }, () => import('@adonisjs/core/providers/http_provider'), ], }) ``` Providers use lazy imports with the `() => import()` syntax, ensuring they're only loaded when needed. ### Built-in service providers A typical AdonisJS application includes several framework providers that handle core functionality. - **app_provider** - Registers fundamental application services and helpers that every AdonisJS app needs. - **hash_provider** - Registers the hash service used for password hashing and verification. - **repl_provider** - Adds REPL-specific bindings. Notice it only runs in the `repl` and `test` environments, demonstrating environment restrictions. - **http_provider** - Sets up the HTTP server and related services for handling web requests. When you install additional packages like `@adonisjs/lucid` for database access or `@adonisjs/auth` for authentication, these packages include their own service providers that you add to this array. ### Execution order and environments AdonisJS calls lifecycle hooks in phases across all registered providers. First, the `register` hook runs for all providers in the order they are registered. Then the `boot` hook runs for all providers in the order they are registered, followed by `start`, `ready`, and finally `shutdown`. Environment restrictions determine whether a provider runs at all. For instance, a WebSocket provider configured for the `web` environment won't execute when you run console commands. This combination of execution order and environment filtering gives you precise control over what runs and when. ## When to create a service provider Create a custom service provider when you need to register services into the IoC container, extend framework classes with macros, perform initialization at specific lifecycle points, set up resources that require cleanup during shutdown, or configure third-party packages application-wide. You typically don't need a service provider for simple utility functions, one-off setup that only runs in a single place, or services used within a single controller or middleware. In these cases, use regular modules or inject dependencies directly. ## Creating a custom service provider Now that you understand when service providers are appropriate, let's build one that registers a `Cache` service into the IoC container. ::::steps :::step{title="Generate the provider"} AdonisJS includes a command to generate service provider files. ```bash node ace make:provider cache ``` ```bash # Output: # CREATE: providers/cache_provider.ts ``` This command creates the provider file and automatically registers it in your `adonisrc.ts` file. ::: :::step{title="Understand the generated code"} Open the generated `providers/cache_provider.ts` file. You'll see a basic provider structure. ```ts title="providers/cache_provider.ts" import type { ApplicationService } from '@adonisjs/core/types' export default class CacheProvider { constructor(protected app: ApplicationService) {} /** * Called when the provider is registered */ register() {} /** * Called when the application boots */ async boot() {} /** * Called when the application starts */ async start() {} /** * Called when the application is ready */ async ready() {} /** * Called during graceful shutdown */ async shutdown() {} } ``` The provider receives the `ApplicationService` through its constructor, giving you access to the IoC container and other application services. All lifecycle methods are optional. You only implement the hooks you need. ::: :::step{title="Register a container binding"} Let's register a simple Cache class into the container using the `register` method. For this example, we'll create a minimal Cache class in the same file, though in a real-world package this class would typically live elsewhere. ```ts title="providers/cache_provider.ts" import type { ApplicationService } from '@adonisjs/core/types' /** * A simple Cache service. * In real-world packages, this would be in a separate * file like src/cache.ts */ export class Cache { get(key: string) { // Implementation would go here return null } set(key: string, value: any) { // Implementation would go here } } export default class CacheProvider { constructor(protected app: ApplicationService) {} register() { this.app.container.bind(Cache, () => { return new Cache() }) } } ``` ::: :::step{title="Use your registered service"} Once registered, you can inject the Cache service into controllers or other container-managed classes. ```ts title="app/controllers/posts_controller.ts" import { inject } from '@adonisjs/core' import type { HttpContext } from '@adonisjs/core/http' export default class PostsController { @inject() constructor(protected cache: Cache) {} async index({ response }: HttpContext) { const cachedPosts = this.cache.get('posts') if (cachedPosts) { return response.json(cachedPosts) } // Fetch from database and cache... return response.json([]) } } ``` ::: :::: ## Understanding all lifecycle hooks Service providers offer five lifecycle hooks that run at different stages of your application's lifetime. Here's when each hook executes: | Hook | Type | When It Runs | Common Use Cases | |------|------|-------------|------------------| | `register` | Sync | Immediately on provider import | Register IoC container bindings | | `boot` | Async | After all providers registered | Extend framework classes, configure services | | `start` | Async | Before HTTP server starts / command runs | Register routes, warm caches | | `ready` | Async | After HTTP server ready / before command runs | Attach to running server (WebSockets) | | `shutdown` | Async | During graceful termination | Close connections, cleanup resources | ### The register hook The `register` method is called as soon as AdonisJS imports your provider, very early in the boot process before any other hooks run. Its primary purpose is to register bindings into the IoC container. ```ts title="providers/database_provider.ts" import type { ApplicationService } from '@adonisjs/core/types' export default class DatabaseProvider { constructor(protected app: ApplicationService) {} register() { this.app.container.singleton('database', () => { return new Database(this.app.config.get('database')) }) } } ``` The `register` hook is synchronous and must remain synchronous. Don't attempt to resolve bindings, perform I/O operations, or access framework services that might not be ready yet. ### The boot hook The `boot` method runs after all providers have finished registering their bindings. At this point, the container is fully populated and you can safely resolve any binding. This makes `boot` the natural place to extend framework classes or configure services that depend on other registered bindings. ```ts title="providers/response_extension_provider.ts" import { HttpResponse } from '@adonisjs/core/http' import type { ApplicationService } from '@adonisjs/core/types' export default class ResponseExtensionProvider { constructor(protected app: ApplicationService) {} async boot() { HttpResponse.macro('apiSuccess', function (data: any) { return this.json({ success: true, data, }) }) } } ``` Use `boot` to configure validators with custom rules, register Edge template helpers, or set up any service that depends on other container bindings being available. ### The start hook The `start` method runs just before the HTTP server starts (in the web environment) or before an Ace command executes (in the console environment). Preload files are imported after this hook completes. ```ts title="providers/routes_provider.ts" import router from '@adonisjs/core/services/router' import type { ApplicationService } from '@adonisjs/core/types' export default class RoutesProvider { constructor(protected app: ApplicationService) {} async start() { /** * Load routes from database or external configuration */ const dynamicRoutes = await this.loadRoutesFromDatabase() dynamicRoutes.forEach((route) => { router.get(route.path, route.handler) }) } private async loadRoutesFromDatabase() { // Implementation would fetch routes from database return [] } } ``` Use `start` to register routes, warm caches, or perform health checks on external services. ### The ready hook The `ready` method runs after the HTTP server has started accepting connections (in the web environment) or just before executing an Ace command's `run` method (in the console environment). This is your last opportunity to perform setup that requires a fully initialized application. ```ts title="providers/websocket_provider.ts" import type { ApplicationService } from '@adonisjs/core/types' import { Server } from 'socket.io' export default class WebSocketProvider { constructor(protected app: ApplicationService) {} async ready() { if (this.app.getEnvironment() === 'web') { const httpServer = await this.app.container.make('server') const io = new Server(httpServer, { cors: { origin: '*', }, }) this.app.container.singleton('websocket', () => io) io.on('connection', (socket) => { console.log('Client connected:', socket.id) }) } } } ``` Use `ready` to integrate services that must attach to the running HTTP server, such as WebSocket servers, or to perform post-startup tasks like sending notifications that the application is online. ### The shutdown hook The `shutdown` method runs when AdonisJS receives a signal to terminate gracefully. This is your opportunity to clean up resources, close connections, and ensure your application shuts down without losing data or leaving dangling processes. ```ts title="providers/database_provider.ts" import type { ApplicationService } from '@adonisjs/core/types' export default class DatabaseProvider { constructor(protected app: ApplicationService) {} register() { this.app.container.singleton('database', () => { return new Database(this.app.config.get('database')) }) } async shutdown() { const database = await this.app.container.make('database') await database.closeAllConnections() console.log('Database connections closed') } } ``` Use `shutdown` to close database connection pools, flush pending log writes, disconnect from Redis, close file handles, or perform any other cleanup necessary for graceful termination. The framework waits for all `shutdown` hooks to complete before exiting. --- # Container Services This guide covers container services in AdonisJS. You will learn: - What container services are and how they work - How to use existing services in your application - When to use services versus dependency injection - How to create your own services for packages ## Overview Container services are a convenience pattern in AdonisJS that simplifies how you access framework functionality. When you need to use features like routing, hashing, or logging, you can import a ready-to-use instance instead of manually constructing classes or interacting with the IoC container directly. This pattern exists because many framework components require dependencies that the IoC container already knows how to provide. Rather than making you resolve these dependencies yourself in every file, AdonisJS packages expose pre-configured instances as standard ES module exports. You import them like any other module, and they work immediately. ## Understanding container services Without container services, you have two options for using framework classes. You could import a class and construct it yourself, manually providing all its dependencies. ```ts title="Manual construction" import { Router } from '@adonisjs/core/http' export const router = new Router(/** Router dependencies */) ``` Alternatively, you could use the IoC container's `make` method to construct the class, letting the container handle dependency resolution. ```ts title="Using app.make()" import app from '@adonisjs/core/services/app' import { Router } from '@adonisjs/core/http' export const router = await app.make(Router) ``` Container services eliminate this ceremony by doing exactly what the second approach does, but packaging it as a convenient import. The service module uses the IoC container internally and exports the resolved instance. ```ts title="Using a container service" import router from '@adonisjs/core/services/router' import hash from '@adonisjs/core/services/hash' import logger from '@adonisjs/core/services/logger' ``` When you import a service, you're getting a singleton instance that was constructed by the IoC container with all its dependencies properly injected. The service itself is just a thin wrapper that makes this instance available as a standard module export. ## Using container services Container services are available automatically when you install AdonisJS packages. No configuration or registration is required. You simply import the service and use it. Here's an example using the Drive service to upload a file to S3. ```ts title="app/controllers/posts_controller.ts" import drive from '@adonisjs/drive/services/main' export class PostsController { async store(post: Post, coverImage: File) { const coverImageName = 'random_name.jpg' /** * The drive service gives you direct access to the * DriveManager instance. Use it to select a disk * and perform file operations. */ const disk = drive.use('s3') await disk.put(coverImageName, coverImage) post.coverImage = coverImageName await post.save() } } ``` This approach is straightforward and requires no setup beyond importing the service. The Drive service is a singleton, so the same instance is shared across your entire application. ## Using dependency injection instead For applications that prefer dependency injection, you can inject the underlying class directly into your services or controllers. This approach makes your code more testable since dependencies can be easily mocked or stubbed. Here's the same file upload functionality using constructor injection. ```ts title="app/services/post_service.ts" import { Disk } from '@adonisjs/drive' import { inject } from '@adonisjs/core' @inject() export class PostService { /** * The Disk instance is injected by the IoC container. * This makes it easy to swap implementations during * testing or use different disk configurations. */ constructor(protected disk: Disk) { } async save(post: Post, coverImage: File) { const coverImageName = 'random_name.jpg' await this.disk.put(coverImageName, coverImage) post.coverImage = coverImageName await post.save() } } ``` With dependency injection, the IoC container automatically resolves and injects the Disk instance. Your class declares what it needs, and the container provides it. This pattern is particularly valuable when writing business logic that needs to remain decoupled from framework specifics. ## Available services AdonisJS core and official packages expose the following container services. Each service corresponds to a container binding and provides access to the fully constructed class instance.
Binding Class Service
app Application @adonisjs/core/services/app
ace Kernel @adonisjs/core/services/kernel
config Config @adonisjs/core/services/config
encryption Encryption @adonisjs/core/services/encryption
emitter Emitter @adonisjs/core/services/emitter
hash HashManager @adonisjs/core/services/hash
logger LoggerManager @adonisjs/core/services/logger
repl Repl @adonisjs/core/services/repl
router Router @adonisjs/core/services/router
server Server @adonisjs/core/services/server
testUtils TestUtils @adonisjs/core/services/test_utils
## Creating your own services If you're building a package or want to expose your own container bindings as services, you can follow the same pattern that AdonisJS uses internally. A container service is simply a module that resolves a binding from the container and exports it. You can view the [complete implementation on GitHub](https://github.com/adonisjs/drive/blob/4.x/services/main.ts#L19-L21) to see how the Drive package creates its service. ```ts title="Example service structure" import app from '@adonisjs/core/services/app' let drive: DriveManager await app.booted(async () => { drive = await app.container.make('drive') }) export { drive as default } ``` The service waits for the application to boot, then resolves the binding from the container and exports it. This ensures all service providers have registered their bindings before the service attempts to resolve them. --- # Barrel Files This guide covers barrel files in AdonisJS and how they reduce import clutter in your codebase. You will learn about: - What barrel files are and where they're stored - Why they exist (reducing import clutter) - How auto-generation works - How to disable them if needed ## Overview A barrel file is an auto-generated collection of exports for a specific entity type in your application. AdonisJS creates barrel files for controllers, bouncer policies, events, and event listeners, storing them in the `.adonisjs/server` directory. Barrel files are completely optional. You can continue using direct imports if you prefer, and [disable barrel file generation](#disabling-barrel-files) entirely through configuration. ## The problem: Import clutter As your application grows, files like `start/routes.ts` accumulate dozens of controller imports. In the following example, with just four controllers, the imports already consume significant vertical space. In production applications with 20+ controllers, you spend more time scrolling past imports than working with routes. ```ts title="start/routes.ts" import router from '@adonisjs/core/services/router' const NewAccountController = () => import('#controllers/new_account_controller') const SessionController = () => import('#controllers/session_controller') const PostsController = () => import('#controllers/posts_controller') const PostCommentsController = () => import('#controllers/post_comments_controller') router.get('signup', [NewAccountController, 'create']) router.post('signup', [NewAccountController, 'store']) router.get('login', [SessionController, 'create']) router.post('login', [SessionController, 'store']) router.get('posts', [PostsController, 'index']) router.get('posts/:id', [PostsController, 'show']) router.get('posts/:id/comments', [PostCommentsController, 'index']) ``` ## The solution: Barrel files Barrel files consolidate all those individual imports into a single import statement. Here's the same routes file using the controllers barrel file: ```ts title="start/routes.ts" import router from '@adonisjs/core/services/router' import { controllers } from '#generated/controllers' router.get('signup', [controllers.NewAccount, 'create']) router.post('signup', [controllers.NewAccount, 'store']) router.get('login', [controllers.Session, 'create']) router.post('login', [controllers.Session, 'store']) router.get('posts', [controllers.Posts, 'index']) router.get('posts/:id', [controllers.Posts, 'show']) router.get('posts/:id/comments', [controllers.PostComments, 'index']) ``` The difference is immediately visible. Four imports become one, and your routes are right at the top of the file where you need them. The only change to your route definitions is using the `controllers` namespace to access each controller. ## How barrel files work The barrel file itself is remarkably simple. It's just a JavaScript object mapping controller names to lazy import functions. Here's what `.adonisjs/server/controllers.ts` looks like: ```ts title=".adonisjs/server/controllers.ts" export const controllers = { NewAccount: () => import('#controllers/new_account_controller'), Session: () => import('#controllers/session_controller'), Posts: () => import('#controllers/posts_controller'), PostComments: () => import('#controllers/post_comments_controller') } ``` The dev server generates this file automatically when you run `node ace serve`. As you create or delete controllers during development, the dev server's watcher updates the barrel file to stay in sync with your codebase. ### File locations and import aliases Barrel files are organized in the `.adonisjs/server` directory with corresponding import aliases: | Barrel File | Import Path | Purpose | |-------------|-------------|---------| | `controllers.ts` | `#generated/controllers` | Controller exports | | `policies.ts` | `#generated/policies` | Bouncer policies exports | | `events.ts` | `#generated/events` | Event exports | | `listeners.ts` | `#generated/listeners` | Event listener exports | The `.adonisjs/server` directory is registered as a subpath import alias in your `package.json`, allowing you to use the `#generated` prefix. ```ts import { controllers } from '#generated/controllers' import { events } from '#generated/events' import { listeners } from '#generated/listeners' ``` :::note The `.adonisjs` directory contains auto-generated files managed by the framework. You should not manually edit files in this directory, as your changes will be overwritten when the dev server regenerates them. ::: :::tip You should commit the `.adonisjs` directory to version control. These files are required for TypeScript to resolve imports like `#generated/controllers`, and without them your production builds and CI pipelines will fail. ::: ## Performance and lazy loading You might wonder if importing all controllers at once hurts performance. The answer is no, because barrel files use **lazy imports**. Each controller in the barrel file is wrapped in a function that returns a dynamic import. ```ts { Posts: () => import('#controllers/posts_controller') } ``` The `() => import()` function is only called when you actually use that controller in a route. Until then, the controller module is never loaded. This means barrel files have zero performance impact. Controllers are still loaded on-demand, exactly as they would be with direct imports. ## Disabling barrel files If you prefer not to use barrel files, you can disable their generation through the `adonisrc.ts` configuration file. The generation is managed using the `init` assembler hook. ```ts title="adonisrc.ts" import { indexEntities } from '@adonisjs/core' import { defineConfig } from '@adonisjs/core/app' export default defineConfig({ // ...other config hooks: { init: [ // [!code highlight:11] indexEntities({ controllers: { enabled: false, }, events: { enabled: false, }, listeners: { enabled: false, } }) ] } }) ``` After disabling barrel file generation, existing barrel files will remain in the `.adonisjs/server` directory. You'll need to manually remove them and update any code that references them to use direct imports instead. --- # Assembler hooks This guide covers Assembler hooks in AdonisJS. You will learn how to: - Register hooks to respond to lifecycle events during development, testing, and builds - React to file changes in watch mode - Hook into the routes scanning pipeline - Generate barrel files and type declarations using the IndexGenerator - Create custom code generation workflows ## Overview Assembler is the build tooling layer of AdonisJS that manages your application as a child process. It handles starting the development server, running tests, and creating production builds. Hooks let you tap into this lifecycle to run custom actions at specific moments, such as generating barrel files when controllers change, creating type declarations when routes are scanned, or displaying custom information when the server starts. Because Assembler runs in a separate process from your AdonisJS application, hooks do not have access to framework features like the IoC container, router service, or database connections. Instead, hooks receive purpose-built utilities like the `IndexGenerator` for code generation and scanner instances for route analysis. Common use cases for Assembler hooks include generating barrel files for lazy-loading controllers, creating type-safe API clients from route metadata, running code generators when files change, and displaying custom startup information. ## Hooks reference The following table lists all available hooks, when they execute, and what parameters they receive. | Hook | Triggered by | Description | |------|--------------|-------------| | `init` | DevServer, TestRunner, Bundler | First hook executed. Use for initialization tasks | | `devServerStarting` | DevServer | Before the child process starts | | `devServerStarted` | DevServer | After the child process is running | | `testsStarting` | TestRunner | Before tests begin executing | | `testsFinished` | TestRunner | After tests complete | | `buildStarting` | Bundler | Before production build begins | | `buildFinished` | Bundler | After production build completes | | `fileChanged` | DevServer, TestRunner | When a file is modified in watch mode | | `fileAdded` | DevServer, TestRunner | When a file is created | | `fileRemoved` | DevServer, TestRunner | When a file is deleted | | `routesCommitted` | DevServer | When routes are registered by the app | | `routesScanning` | DevServer | Before route type scanning begins | | `routesScanned` | DevServer | After route type scanning completes | ## Creating and registering hooks Hooks are registered in the `adonisrc.ts` file under the `hooks` property. Each hook accepts an array of lazy-loaded imports, allowing you to split hook logic into separate files and only load them when needed. ```ts title="adonisrc.ts" import { defineConfig } from '@adonisjs/core/app' export default defineConfig({ hooks: { devServerStarted: [() => import('./hooks/on_server_started.ts')], fileChanged: [() => import('./hooks/on_file_changed.ts')], }, }) ``` The hook file must export a default function that receives the hook's parameters. Each hook has a typed helper available from `@adonisjs/core/app` that provides full TypeScript support for the parameters. ```ts title="hooks/on_server_started.ts" import { hooks } from '@adonisjs/core/app' export default hooks.devServerStarted((devServer, info, instructions) => { /** * info.host - The host address the server is bound to * info.port - The port number the server is running on * instructions - UI helper for displaying formatted output */ console.log(`Server running at http://${info.host}:${info.port}`) }) ``` You can register multiple hooks for the same event. They execute in the order they are registered. ```ts title="adonisrc.ts" import { defineConfig } from '@adonisjs/core/app' export default defineConfig({ hooks: { devServerStarted: [ () => import('./hooks/log_server_info.ts'), () => import('./hooks/notify_external_service.ts'), ], }, }) ``` :::warning Assembler hooks run in a separate process from your AdonisJS application. They do not have access to the IoC container, router, database, or any other framework services. If you need to interact with your application, use the routes scanning hooks to extract metadata or communicate via HTTP/IPC. ::: ## Init hook The `init` hook is the first hook executed when Assembler starts any operation. It receives the parent instance (DevServer, TestRunner, or Bundler), a hooks manager for registering additional runtime hooks, and the IndexGenerator for code generation tasks. ```ts title="hooks/init.ts" import { hooks } from '@adonisjs/core/app' export default hooks.init((parent, hooksManager, indexGenerator) => { /** * Determine what operation is running by checking the parent type. * Use indexGenerator to set up barrel file or type generation. */ console.log('Assembler initialized') }) ``` The `init` hook is the recommended place to configure the IndexGenerator for barrel file and type generation, as it runs before any other operations begin. ## Dev server hooks The dev server hooks execute when starting and running the development server. The `devServerStarting` hook fires before the child process launches, and `devServerStarted` fires once the server is accepting connections. ```ts title="hooks/on_dev_server_starting.ts" import { hooks } from '@adonisjs/core/app' export default hooks.devServerStarting((devServer) => { /** * Perform setup tasks before the server starts. * The child process has not been spawned yet. */ console.log('Preparing to start dev server...') }) ``` ```ts title="hooks/on_dev_server_started.ts" import { hooks } from '@adonisjs/core/app' export default hooks.devServerStarted((devServer, info, instructions) => { /** * The server is now running and accepting connections. * Use instructions to add custom UI output. */ instructions.add('custom', `API docs: http://${info.host}:${info.port}/docs`) }) ``` These hooks re-trigger every time the child process restarts, such as when a full reload occurs due to file changes. ## Test runner hooks The test runner hooks execute before and after running your test suite. Use `testsStarting` to set up test fixtures or databases, and `testsFinished` to generate reports or clean up resources. ```ts title="hooks/on_tests_starting.ts" import { hooks } from '@adonisjs/core/app' export default hooks.testsStarting((testRunner) => { console.log('Preparing test environment...') }) ``` ```ts title="hooks/on_tests_finished.ts" import { hooks } from '@adonisjs/core/app' export default hooks.testsFinished((testRunner) => { console.log('Tests complete, generating coverage report...') }) ``` When running tests in watch mode, these hooks re-trigger each time the test suite re-runs. ## Bundler hooks The bundler hooks execute when creating a production build with `node ace build`. Use `buildStarting` for pre-build tasks like asset optimization, and `buildFinished` to display build statistics or run post-build scripts. ```ts title="hooks/on_build_starting.ts" import { hooks } from '@adonisjs/core/app' export default hooks.buildStarting((bundler) => { console.log('Starting production build...') }) ``` ```ts title="hooks/on_build_finished.ts" import { hooks } from '@adonisjs/core/app' export default hooks.buildFinished((bundler, instructions) => { instructions.add('deploy', 'Run `npm run start` in the build folder to start the server') }) ``` ## Watcher hooks The watcher hooks fire when files change during development or test watch mode. In HMR mode, Assembler relies on hot-hook to detect changes; otherwise, the built-in file watcher handles detection. Each watcher hook receives both the relative path (from your application root, using Unix-style slashes) and the absolute path to the affected file. ```ts title="hooks/on_file_changed.ts" import { hooks } from '@adonisjs/core/app' export default hooks.fileChanged((relativePath, absolutePath, info, parent) => { /** * info.source - Either 'hot-hook' or 'watcher' * info.hotReloaded - True if the file was hot reloaded without restart * info.fullReload - True if a full server restart is required */ if (relativePath.startsWith('app/controllers/')) { console.log(`Controller changed: ${relativePath}`) } }) ``` ```ts title="hooks/on_file_added.ts" import { hooks } from '@adonisjs/core/app' export default hooks.fileAdded((relativePath, absolutePath, parent) => { console.log(`New file created: ${relativePath}`) }) ``` ```ts title="hooks/on_file_removed.ts" import { hooks } from '@adonisjs/core/app' export default hooks.fileRemoved((relativePath, absolutePath, parent) => { console.log(`File deleted: ${relativePath}`) }) ``` ## Routes hooks The routes hooks provide access to your application's route definitions and their associated types. These hooks only execute during dev server operation, not during builds or tests. ### Routes committed The `routesCommitted` hook fires when your AdonisJS application registers its routes. The routes are transmitted from the child process to Assembler via IPC, giving you access to route metadata without parsing files yourself. ```ts title="hooks/on_routes_committed.ts" import { hooks } from '@adonisjs/core/app' export default hooks.routesCommitted((devServer, routes) => { /** * routes is an object with domains as keys. * Each domain contains an array of route definitions. */ const defaultRoutes = routes['root'] || [] console.log(`${defaultRoutes.length} routes registered`) }) ``` ### Routes scanning The `routesScanning` hook fires before Assembler begins analyzing your routes to extract request and response types. Use this hook to configure the scanner, such as skipping certain routes from analysis. ```ts title="hooks/on_routes_scanning.ts" import { hooks } from '@adonisjs/core/app' export default hooks.routesScanning((devServer, routesScanner) => { /** * Skip routes that don't need type extraction, * such as authentication endpoints with complex flows. */ routesScanner.skip(['session.create', 'session.store', 'oauth.callback']) }) ``` ### Routes scanned The `routesScanned` hook fires after route analysis completes. The scanner contains extracted type information that you can use to generate API clients or type declarations. ```ts title="hooks/on_routes_scanned.ts" import { hooks } from '@adonisjs/core/app' export default hooks.routesScanned((devServer, routesScanner) => { const routes = routesScanner.getRoutes() const controllers = routesScanner.getControllers() /** * Use this metadata to generate type-safe API clients. * The types are internal import references, not standalone types. */ console.log(`Scanned ${routes.length} routes from ${controllers.length} controllers`) }) ``` :::note The types extracted by the routes scanner are internal import references pointing to your controllers and validators. They are not fully resolved standalone types that can be used in a separate project. Generating standalone types requires additional tooling like [Tuyau](https://tuyau.julr.dev/docs/introduction). ::: ## IndexGenerator The IndexGenerator is a utility for watching directories and generating barrel files or type declarations from their contents. It handles file watching automatically, regenerating output files when source files are added or removed. ### Configuring the IndexGenerator Register IndexGenerator configurations in the `init` hook. Each configuration specifies a source directory to watch, an output file to generate, and how to transform the source files. ```ts title="hooks/init.ts" import { hooks } from '@adonisjs/core/app' export default hooks.init((parent, hooksManager, indexGenerator) => { indexGenerator.add('controllers', { source: './app/controllers', importAlias: '#controllers', as: 'barrelFile', exportName: 'controllers', removeSuffix: 'controller', output: './.adonisjs/server/controllers.ts', }) }) ``` The configuration options are: | Option | Description | |--------|-------------| | `source` | Directory to scan for source files | | `importAlias` | The import alias to use in generated imports (e.g., `#controllers`) | | `as` | Either `'barrelFile'` for automatic generation or a callback for custom output | | `exportName` | Name of the exported constant in barrel files | | `removeSuffix` | Suffix to strip from file names when generating property keys | | `output` | Path where the generated file will be written | ### Barrel file generation When `as` is set to `'barrelFile'`, the IndexGenerator scans the source directory recursively and generates a barrel file that exports lazy-loaded imports. The directory structure is preserved as nested objects. Given this controller structure: ``` app/controllers/ ├── auth/ │ ├── login_controller.ts │ └── register_controller.ts ├── blog/ │ ├── posts_controller.ts │ └── post_comments_controller.ts └── users_controller.ts ``` The IndexGenerator produces: ```ts title=".adonisjs/server/controllers.ts" export const controllers = { auth: { Login: () => import('#controllers/auth/login_controller'), Register: () => import('#controllers/auth/register_controller'), }, blog: { Posts: () => import('#controllers/blog/posts_controller'), PostComments: () => import('#controllers/blog/post_comments_controller'), }, Users: () => import('#controllers/users_controller'), } ``` This barrel file enables lazy-loading controllers in your routes without manual import management. The file automatically updates when you add or remove controllers. ### Custom type generation For generating type declarations or other custom output, pass a callback function to the `as` option. The callback receives a collection of files and a writer utility for building the output string. The files collection is a key-value object where each key is the relative path (without extension) from the source directory, and each value contains the file's `importPath`, `relativePath`, and `absolutePath`. ```ts title="hooks/init.ts" import { hooks } from '@adonisjs/core/app' export default hooks.init((parent, hooksManager, indexGenerator) => { indexGenerator.add('inertiaPages', { source: './inertia/pages', as: (files, writer) => { writer.writeLine(`declare module '@adonisjs/inertia' {`).indent() writer.writeLine(`export interface Pages {`).indent() for (const [filePath, file] of Object.entries(files)) { writer.writeLine( `'${filePath}': InferPageProps` ) } writer.dedent().writeLine('}') writer.dedent().writeLine('}') return writer.toString() }, output: './.adonisjs/server/inertia_pages.d.ts', }) }) ``` This generates a type declaration file that maps page component paths to their prop types, enabling type-safe Inertia.js page rendering. ### Complete IndexGenerator example The following example shows a complete `init` hook that configures barrel file generation for controllers, events, and listeners—the default setup used by the AdonisJS framework. ```ts title="hooks/init.ts" import { hooks } from '@adonisjs/core/app' export default hooks.init((parent, hooksManager, indexGenerator) => { /** * Generate a barrel file for controllers. * Enables lazy-loading in routes: [controllers.Posts, 'index'] */ indexGenerator.add('controllers', { source: './app/controllers', importAlias: '#controllers', as: 'barrelFile', exportName: 'controllers', removeSuffix: 'controller', output: './.adonisjs/server/controllers.ts', }) /** * Generate a barrel file for event classes. * Enables type-safe event emission. */ indexGenerator.add('events', { source: './app/events', importAlias: '#events', as: 'barrelFile', exportName: 'events', output: './.adonisjs/server/events.ts', }) /** * Generate a barrel file for event listeners. * Enables lazy-loading listeners in event bindings. */ indexGenerator.add('listeners', { source: './app/listeners', importAlias: '#listeners', as: 'barrelFile', exportName: 'listeners', output: './.adonisjs/server/listeners.ts', }) }) ``` --- # Scaffolding and codemods This guide covers the scaffolding and codemods system in AdonisJS. You will learn how to: - Create a configure hook for your AdonisJS package - Use codemods to modify the host application's source files - Create stubs to scaffold configuration files and other source code - Customize stub templates with generators and variables - Eject and modify stubs from existing packages ## Overview When you run `node ace configure @adonisjs/lucid`, the package automatically registers its provider, sets up environment variables, and creates a config file in your project. This seamless setup experience is powered by AdonisJS's scaffolding and codemods system. **Scaffolding** refers to generating source files from templates called stubs. **Codemods** are programmatic transformations that modify existing TypeScript source files by parsing and manipulating the AST (Abstract Syntax Tree). Together, they allow package authors to provide the same polished configure experience that official AdonisJS packages offer. The codemods API is powered by [ts-morph](https://github.com/dsherret/ts-morph) and lives in the `@adonisjs/assembler` package. Since assembler is a development dependency, ts-morph never bloats your production bundle. ## Building blocks Before diving into the tutorial, let's briefly define the key components you'll work with. **Stubs** are template files (with a `.stub` extension) that generate source files. They use [Tempura](https://github.com/lukeed/tempura), a lightweight handlebars-style template engine. **Generators** are helper functions that enforce AdonisJS naming conventions. They transform input like `user` into properly formatted names like `UsersController` or `users_controller.ts`. **Codemods** are high-level APIs for common modifications like registering providers, adding middleware, or defining environment variables. They handle the complexity of AST manipulation for you. **Configure hooks** are functions exported by packages that run when a user executes `node ace configure `. This is where you combine stubs and codemods to set up your package. ## Creating a configure hook The most common use of scaffolding and codemods is creating a configure hook for an AdonisJS package. Let's build one step-by-step using a cache package as our example. ::::steps :::step{title="Set up the package structure"} A typical AdonisJS package with a configure hook has this structure: ``` my-cache-package/ ├── src/ │ └── ... ├── stubs/ │ ├── config.stub │ └── main.ts ├── configure.ts ├── index.ts └── package.json ``` The `stubs` directory contains your template files, `configure.ts` holds the configure function, and `index.ts` exports everything including the configure hook. ::: :::step{title="Install @adonisjs/assembler as a peer dependency"} The codemods API requires `@adonisjs/assembler`, which must be installed as a **peer dependency** in your package. This is important because the host application already has assembler installed as a dev dependency, and it should be shared across all configured packages rather than duplicated. ```json title="package.json" { "name": "@adonisjs/cache", "peerDependencies": { "@adonisjs/assembler": "^7.0.0" } } ``` When users install your package and run `node ace configure`, the assembler from their project will be used. ::: :::step{title="Export the stubs root"} Create a `stubs/main.ts` file that exports the path to your stubs directory. This path is needed when calling `makeUsingStub`. ```ts title="stubs/main.ts" export const stubsRoot = import.meta.url ``` ::: :::step{title="Write the configure function"} The configure function receives the Configure command instance, which provides access to the codemods API. Here's a complete example for a cache package: ```ts title="configure.ts" import type Configure from '@adonisjs/core/commands/configure' import { stubsRoot } from './stubs/main.ts' export async function configure(command: Configure) { const codemods = await command.createCodemods() /** * Register the provider and commands in the adonisrc.ts file */ await codemods.updateRcFile((rcFile) => { rcFile .addProvider('@adonisjs/cache/cache_provider') .addCommand('@adonisjs/cache/commands') }) /** * Add environment variables to .env and .env.example files */ await codemods.defineEnvVariables({ CACHE_STORE: 'redis', }) /** * Add validation rules to start/env.ts */ await codemods.defineEnvValidations({ variables: { CACHE_STORE: `Env.schema.string()`, }, }) /** * Create the config/cache.ts file from a stub */ await codemods.makeUsingStub(stubsRoot, 'config.stub', { store: 'redis', }) } ``` ::: :::step{title="Export from the package entry point"} Export the configure function from your package's main entry point so the `node ace configure` command can find it: ```ts title="index.ts" export { configure } from './configure.ts' ``` When users run `node ace configure @adonisjs/cache`, AdonisJS imports this file and executes the exported `configure` function. ::: :::: ## Creating stubs Stubs are template files that generate source code. They combine static content with dynamic values computed at runtime. ### Basic stub syntax Stubs use double curly braces for variable interpolation. Here's a simple config stub. :::tip Since Tempura's syntax is compatible with Handlebars, configure your editor to use Handlebars syntax highlighting for `.stub` files. ::: ```handlebars title="stubs/config.stub" {{{ exports({ to: app.configPath('cache.ts') }) }}} import { defineConfig, stores } from '@adonisjs/cache' export default defineConfig({ default: '{{ store }}', stores: { redis: stores.redis({}), }, }) ``` The `exports` function at the top defines metadata about the generated file, most importantly the destination path. The `app` variable provides access to application paths like `configPath`, `makePath`, and `httpControllersPath`. ### Using generators for naming conventions When creating stubs that need to follow AdonisJS naming conventions, use the generators module. Generators transform user input into properly formatted names. ```handlebars title="stubs/resource.stub" {{#var entity = generators.createEntity(name)}} {{#var modelName = generators.modelName(entity.name)}} {{#var modelReference = string.camelCase(modelName)}} {{#var resourceFileName = string(modelName).snakeCase().suffix('_resource').ext('.ts').toString()}} {{{ exports({ to: app.makePath('app/api_resources', entity.path, resourceFileName) }) }}} export default class {{ modelName }}Resource { serialize({{ modelReference }}: {{ modelName }}) { return {{ modelReference }}.toJSON() } } ``` The `{{#var ...}}` syntax creates inline variables within the stub. This approach keeps all the naming logic inside the stub itself, which becomes important when users eject stubs to customize them. ### Passing data to stubs When calling `makeUsingStub`, pass a data object as the third argument. These values become available in the stub template: ```ts title="configure.ts" await codemods.makeUsingStub(stubsRoot, 'config.stub', { store: 'dynamodb', region: 'us-east-1', }) ``` ```handlebars title="stubs/config.stub" {{{ exports({ to: app.configPath('cache.ts') }) }}} export default defineConfig({ default: '{{ store }}', region: '{{ region }}', }) ``` ### Global stub variables Every stub has access to these built-in variables: | Variable | Description | |----------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------| | `app` | Reference to the [application class](../../reference/application.md) instance with path helpers. | | `generators` | Reference to the [generators module](https://github.com/adonisjs/application/blob/-/src/generators.ts) for naming conventions. | | `randomString` | Reference to the [randomString](../../reference/helpers.md#string) helper function. | | `string` | A function to create a [string builder](../../reference/helpers.md) instance for transformations. | | `flags` | Command-line flags passed when running the ace command. | ## Using stubs in commands Beyond configure hooks, you can use stubs in your own Ace commands. This is useful for creating scaffolding commands like `make:resource` or `make:service`. ```ts title="commands/make_resource.ts" import { join } from 'node:path' import { BaseCommand, args } from '@adonisjs/core/ace' const STUBS_ROOT = join(import.meta.dirname, '../stubs') export default class MakeResource extends BaseCommand { static commandName = 'make:resource' static description = 'Create a new API resource' @args.string({ description: 'Name of the resource' }) declare name: string async run() { const codemods = await this.createCodemods() await codemods.makeUsingStub(STUBS_ROOT, 'resource.stub', { name: this.name, }) } } ``` ## Ejecting stubs Host applications can customize stub templates by ejecting them. The `node ace eject` command copies stubs from a package into the project's `stubs` directory. ### Ejecting a single stub ```sh node ace eject make/controller/main.stub ``` This copies the controller stub from `@adonisjs/core` to `stubs/make/controller/main.stub` in your project. Any future `make:controller` calls will use your customized version. ### Ejecting directories Copy an entire directory of stubs: ```sh # All make stubs node ace eject make # All controller-related stubs node ace eject make/controller ``` ### Ejecting from other packages By default, `eject` copies from `@adonisjs/core`. Use the `--pkg` flag for other packages: ```sh node ace eject make/migration/main.stub --pkg=@adonisjs/lucid ``` ### Using CLI flags to customize output Scaffolding commands share CLI flags with stub templates through the `flags` variable. You can use this to create custom workflows: ```sh node ace make:controller invoice --feature=billing ``` ```handlebars title="stubs/make/controller/main.stub" {{#var controllerName = generators.controllerName(entity.name)}} {{#var featureDirectoryName = flags.feature}} {{#var controllerFileName = generators.controllerFileName(entity.name)}} {{{ exports({ to: app.makePath('features', featureDirectoryName, controllerFileName) }) }}} // import type { HttpContext } from '@adonisjs/core/http' export default class {{ controllerName }} { } ``` ### Finding stubs to eject Each package stores its stubs in a `stubs` directory at the package root. Visit the package's GitHub repository to see what's available. ## Stubs execution flow When you call `makeUsingStub`, the following happens: 1. AdonisJS first checks for an ejected stub in the host project's `stubs` directory 2. If not found, it uses the original stub from your package 3. The stub template is processed with Tempura, evaluating all variables and expressions 4. The `exports()` function determines the output path 5. The generated file is written to the destination ![](./scaffolding_workflow.png) ## Codemods API reference The codemods API provides high-level methods for common source file modifications. All methods are available on the codemods instance returned by `command.createCodemods()`. :::note The codemods API relies on AdonisJS's default file structure and naming conventions. If you've made significant changes to your project structure, some codemods may not work as expected. ::: ### updateRcFile Register providers, commands, meta files, and command aliases in `adonisrc.ts`. ```ts await codemods.updateRcFile((rcFile) => { rcFile .addProvider('@adonisjs/lucid/db_provider') .addCommand('@adonisjs/lucid/commands') .setCommandAlias('migrate', 'migration:run') }) ``` **Output:** ```ts title="adonisrc.ts" import { defineConfig } from '@adonisjs/core/app' export default defineConfig({ commands: [ () => import('@adonisjs/lucid/commands') ], providers: [ () => import('@adonisjs/lucid/db_provider') ], commandAliases: { migrate: 'migration:run' } }) ``` ### defineEnvVariables Add environment variables to `.env` and `.env.example` files. ```ts await codemods.defineEnvVariables({ REDIS_HOST: 'localhost', REDIS_PORT: '6379', }) ``` To omit the value from `.env.example` (useful for secrets), use the `omitFromExample` option: ```ts await codemods.defineEnvVariables({ API_KEY: 'secret-key-here', }, { omitFromExample: ['API_KEY'] }) ``` This inserts `API_KEY=secret-key-here` in `.env` and `API_KEY=` in `.env.example`. ### defineEnvValidations Add validation rules to `start/env.ts`. The codemod does not overwrite existing rules, respecting any customizations the user has made. ```ts await codemods.defineEnvValidations({ leadingComment: 'Cache environment variables', variables: { CACHE_STORE: 'Env.schema.string()', CACHE_TTL: 'Env.schema.number.optional()', } }) ``` **Output:** ```ts title="start/env.ts" import { Env } from '@adonisjs/core/env' export default await Env.create(new URL('../', import.meta.url), { /** * Cache environment variables */ CACHE_STORE: Env.schema.string(), CACHE_TTL: Env.schema.number.optional(), }) ``` ### registerMiddleware Register middleware to one of the middleware stacks: `server`, `router`, or `named`. ```ts // Router middleware await codemods.registerMiddleware('router', [ { path: '@adonisjs/core/bodyparser_middleware' } ]) // Named middleware await codemods.registerMiddleware('named', [ { name: 'auth', path: '@adonisjs/auth/auth_middleware' } ]) ``` **Output:** ```ts title="start/kernel.ts" import router from '@adonisjs/core/services/router' router.use([ () => import('@adonisjs/core/bodyparser_middleware') ]) export const middleware = router.named({ auth: () => import('@adonisjs/auth/auth_middleware') }) ``` ### registerJapaPlugin Register a Japa testing plugin in `tests/bootstrap.ts`. ```ts await codemods.registerJapaPlugin( 'sessionApiClient(app)', [ { isNamed: false, module: '@adonisjs/core/services/app', identifier: 'app' }, { isNamed: true, module: '@adonisjs/session/plugins/api_client', identifier: 'sessionApiClient' } ] ) ``` **Output:** ```ts title="tests/bootstrap.ts" import app from '@adonisjs/core/services/app' import { sessionApiClient } from '@adonisjs/session/plugins/api_client' export const plugins: Config['plugins'] = [ sessionApiClient(app) ] ``` ### registerPolicies Register bouncer policies in `app/policies/main.ts`. ```ts await codemods.registerPolicies([ { name: 'PostPolicy', path: '#policies/post_policy' } ]) ``` **Output:** ```ts title="app/policies/main.ts" export const policies = { PostPolicy: () => import('#policies/post_policy') } ``` ### registerVitePlugin Register a Vite plugin in `vite.config.ts`. ```ts await codemods.registerVitePlugin( 'vue({ jsx: true })', [ { isNamed: false, module: '@vitejs/plugin-vue', identifier: 'vue' } ] ) ``` **Output:** ```ts title="vite.config.ts" import { defineConfig } from 'vite' import vue from '@vitejs/plugin-vue' export default defineConfig({ plugins: [ vue({ jsx: true }) ] }) ``` ### installPackages Install npm packages using the project's detected package manager. ```ts await codemods.installPackages([ { name: 'vinejs', isDevDependency: false }, { name: '@types/lodash', isDevDependency: true } ]) ``` --- # Extending the framework This guide covers how to extend AdonisJS with custom functionality. You will learn how to: - Add custom methods to framework classes using macros - Create computed properties with getters - Ensure type safety with TypeScript declaration merging - Organize extension code in your application - Extend specific framework modules like Hash, Session, and Authentication ## Overview AdonisJS provides a powerful extension system that lets you add custom methods and properties to framework classes without modifying the framework's source code. This means you can enhance the `HttpRequest` class with custom validation logic, add utility methods to the `HttpResponse` class, or extend any other framework class to fit your application's specific needs. The extension system is built on two core concepts: **macros** (custom methods) and **getters** (computed properties). Both are added at runtime and integrate seamlessly with TypeScript through declaration merging, giving you full type safety and autocomplete in your editor. This same extension API is used throughout AdonisJS's own first-party packages, making it a proven pattern for building reusable functionality. Whether you're adding a few helper methods for your application or building a package to share with the community, the extension system provides a clean, type-safe way to enhance the framework. ## Why extend the framework? Before diving into the mechanics, let's understand when and why you'd want to extend framework classes. Without extensions, you'd need to write the same logic repeatedly across your application. For example, checking if a request expects JSON responses: ```ts title="app/controllers/posts_controller.ts" export default class PostsController { async index({ request, response }: HttpContext) { // Repeated in every action that returns different formats const acceptHeader = request.header('accept', '') const wantsJSON = acceptHeader.includes('application/json') || acceptHeader.includes('+json') if (wantsJSON) { return response.json({ posts: [] }) } return view.render('posts/index') } } ``` With a macro, you write this logic once and use it everywhere: ```ts title="src/extensions.ts" import { HttpRequest } from '@adonisjs/core/http' /** * Check if the request expects a JSON response based on Accept header */ HttpRequest.macro('wantsJSON', function (this: HttpRequest) { const firstType = this.types()[0] if (!firstType) { return false } return firstType.includes('/json') || firstType.includes('+json') }) ``` ```ts title="app/controllers/posts_controller.ts" export default class PostsController { async index({ request, response }: HttpContext) { if (request.wantsJSON()) { return response.json({ posts: [] }) } return view.render('posts/index') } } ``` Extensions are ideal when you: - Have framework-specific logic reused across your application - Want to maintain AdonisJS's fluent API style - Are building a package that integrates deeply with the framework - Need type-safe custom functionality with autocomplete support ## Understanding macros and getters Before we start adding extensions, let's clarify what macros and getters are and when to use each. **Macros** are custom methods you add to a class. They work like regular methods and can accept parameters, perform computations, and return values. Use macros when you need functionality that requires input or performs actions. **Getters** are computed properties that look like regular properties when you access them. They're calculated on-demand and can optionally cache their result. Use getters for read-only derived data that doesn't require parameters. Both macros and getters use **declaration merging**, a TypeScript feature that extends existing type definitions to include your custom additions. This ensures your extensions have full type safety and autocomplete support. Under the hood, AdonisJS uses the [macroable](https://github.com/poppinss/macroable) package to implement this functionality. If you want to understand the implementation details, you can refer to that package's documentation. ## Creating your first macro Let's build a simple macro step-by-step. We'll add a method to the `HttpRequest` class that checks if the incoming request is from a mobile device. ::::steps :::step{title="Create the extensions file"} Create a dedicated file to hold all your framework extensions. This keeps your extension code organized in one place. ```ts title="src/extensions.ts" // This file contains all framework extensions for your application ``` The file can be named anything you like, but `extensions.ts` clearly communicates its purpose. ::: :::step{title="Import the class you want to extend"} Import the framework class you want to add functionality to. For our example, we'll extend the `HttpRequest` class. ```ts title="src/extensions.ts" import { HttpRequest } from '@adonisjs/core/http' ``` ::: :::step{title="Add the macro method"} Use the `macro` method to add your custom functionality. The method receives the class instance as `this`, giving you access to all the class's existing properties and methods. ```ts title="src/extensions.ts" import { HttpRequest } from '@adonisjs/core/http' HttpRequest.macro('isMobile', function (this: HttpRequest) { /** * Get the User-Agent header, defaulting to empty string if not present */ const userAgent = this.header('user-agent', '') /** * Check if the User-Agent contains common mobile identifiers */ return /mobile|android|iphone|ipad|phone/i.test(userAgent) }) ``` The `function (this: HttpRequest)` syntax is important because it gives you the correct `this` context. Don't use arrow functions here, as they don't preserve the `this` binding. ::: :::step{title="Add TypeScript type definitions"} Tell TypeScript about your new method using declaration merging. Add this at the end of your extensions file. ```ts title="src/extensions.ts" declare module '@adonisjs/core/http' { interface HttpRequest { isMobile(): boolean } } ``` The module path in `declare module` must exactly match the import path you use. The interface name must exactly match the class name. ::: :::step{title="Load extensions in your provider"} Import your extensions file in a service provider's `boot` method to ensure the extensions are registered when your application starts. ```ts title="providers/app_provider.ts" export default class AppProvider { async boot() { await import('../src/extensions.ts') } } ``` ::: :::step{title="Use your macro"} Your macro is now available throughout your application with full type safety and autocomplete. ```ts title="app/controllers/home_controller.ts" import type { HttpContext } from '@adonisjs/core/http' export default class HomeController { async index({ request, view }: HttpContext) { /** * TypeScript knows about isMobile() and provides autocomplete */ if (request.isMobile()) { return view.render('mobile/home') } return view.render('home') } } ``` ::: :::: ## Creating your first getter Getters are computed properties that work like regular properties but are calculated on-demand. Let's add a getter to the `HttpRequest` class that provides a cleaned version of the request path. ```ts title="src/extensions.ts" import { HttpRequest } from '@adonisjs/core/http' HttpRequest.getter('cleanPath', function (this: HttpRequest) { /** * Get the current URL path */ const path = this.url() /** * Remove trailing slashes and convert to lowercase */ return path.replace(/\/+$/, '').toLowerCase() }) ``` ```ts title="src/extensions.ts" declare module '@adonisjs/core/http' { interface HttpRequest { cleanPath: string // Note: property, not a method } } ``` Notice the type declaration differs from macros. Getters are properties, not methods, so you don't include `()` in the type definition. You can use getters like regular properties: ```ts title="app/middleware/log_middleware.ts" export default class LogMiddleware { async handle({ request, logger }: HttpContext, next: NextFn) { /** * Access the getter like a property, not a method */ logger.info('Request path: %s', request.cleanPath) await next() } } ``` :::note Getter callbacks cannot be async because [JavaScript getters](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Functions/get) are synchronous by design. If you need async computation, use a macro instead. ::: ## Singleton getters By default, getters recalculate their value every time you access them. For expensive computations, you can make a getter a **singleton**, which caches the result after the first calculation. ```ts title="src/extensions.ts" import { HttpRequest } from '@adonisjs/core/http' /** * The third parameter (true) makes this a singleton getter */ HttpRequest.getter('ipAddress', function (this: HttpRequest) { /** * Check for proxy headers first, fall back to direct IP * This only runs once per request instance */ return this.header('x-forwarded-for') || this.header('x-real-ip') || this.ips()[0] || this.ip() }, true) ``` ```ts title="src/extensions.ts" declare module '@adonisjs/core/http' { interface HttpRequest { ipAddress: string } } ``` With singleton getters, the function executes once per instance of the class, and the return value is cached for that instance: ```ts const ip1 = request.ipAddress // Executes the getter function const ip2 = request.ipAddress // Returns cached value, doesn't re-execute const ip3 = request.ipAddress // Still returns cached value ``` :::tip Use singleton getters when the computed value won't change during the instance's lifetime. For example, a request's IP address won't change during a single HTTP request, so caching it makes sense. Don't use singleton getters for values that might change, like computed properties based on mutable state. ::: ## When to use macros vs getters Choosing between macros and getters depends on your use case. Here's a practical guide. **Use macros when you need to:** - Accept parameters - Perform actions with side effects - Return different values based on input - Execute async operations ```ts title="src/extensions.ts" /** * Macro example: Accepts a role parameter */ HttpRequest.macro('hasRole', function (this: HttpRequest, role: string) { const user = this.ctx.auth.user return user?.role === role }) // Usage: request.hasRole('admin') ``` **Use getters when you need to:** - Provide computed read-only properties - Calculate derived data from existing properties - Cache expensive computations (with singleton) - Maintain a property-like API ```ts title="src/extensions.ts" /** * Getter example: Computed property with no parameters */ HttpRequest.getter('isAuthenticated', function (this: HttpRequest) { return this.ctx.auth.isAuthenticated }) // Usage: request.isAuthenticated ``` Both can coexist on the same class. Choose based on the API you want to provide. ## Understanding declaration merging Declaration merging is how TypeScript learns about your runtime extensions. Getting this right is crucial for type safety. The module path in your `declare module` statement must exactly match the path you use to import the class: ```ts title="src/extensions.ts" // If you import like this: import { HttpRequest } from '@adonisjs/core/http' // You must declare like this (exact same path): declare module '@adonisjs/core/http' { interface HttpRequest { isMobile(): boolean } } ``` :::warning **Why this matters**: TypeScript uses the module path to determine which type definition to merge with. **What happens**: If the paths don't match, TypeScript won't recognize your extension. You'll see errors like "Property 'isMobile' does not exist on type 'Request'" even though your code runs correctly. **Solution**: Always copy the exact import path when writing your declaration: ```ts // ✅ Correct: Paths match import { HttpRequest } from '@adonisjs/core/http' declare module '@adonisjs/core/http' { ... } // ❌ Wrong: Paths don't match import { HttpRequest } from '@adonisjs/core/http' declare module '@adonisjs/http-server' { ... } ``` ::: You can declare multiple extensions in the same `declare module` block: ```ts title="src/extensions.ts" declare module '@adonisjs/core/http' { interface HttpRequest { isMobile(): boolean hasRole(role: string): boolean cleanPath: string ipAddress: string } } ``` Or split them across multiple blocks if you prefer: ```ts title="src/extensions.ts" declare module '@adonisjs/core/http' { interface HttpRequest { isMobile(): boolean } } declare module '@adonisjs/core/http' { interface HttpRequest { hasRole(role: string): boolean } } ``` Both approaches work identically. Choose based on your organization preferences. ## Common mistakes Here are the most common issues developers encounter when extending the framework and how to fix them. :::tip **Mistake**: Using arrow functions for macros **Why it fails**: Arrow functions don't have their own `this` binding, so you can't access the class instance. ```ts // ❌ Wrong: Arrow function HttpRequest.macro('isMobile', () => { return this.header('user-agent') // `this` is undefined! }) // ✅ Correct: Regular function HttpRequest.macro('isMobile', function (this: HttpRequest) { return this.header('user-agent') // `this` is the Request instance }) ``` ::: :::tip **Mistake**: Forgetting the singleton parameter defaults to `false` **What happens**: Your getter recalculates every time it's accessed, even if the value won't change. ```ts // This executes the function every single time HttpRequest.getter('expensiveCalculation', function (this: HttpRequest) { return someExpensiveOperation() }) // Add true for singleton to cache the result HttpRequest.getter('expensiveCalculation', function (this: HttpRequest) { return someExpensiveOperation() }, true) // Caches after first access ``` ::: :::tip **Mistake**: Treating getters like methods **What happens**: You'll get errors because getters are properties, not functions. ```ts HttpRequest.getter('ipAddress', function (this: HttpRequest) { return this.ip() }) // ❌ Wrong: Calling it like a method const ip = request.ipAddress() // ✅ Correct: Accessing it like a property const ip = request.ipAddress ``` ::: ## Macroable classes The following framework classes support macros and getters. Each entry includes the import path and typical use cases. ::::options :::option{name="Application" import="@adonisjs/core/app"} The main application instance. Extend this to add application-level utilities. **Common use cases**: Add custom environment checks, application state getters, or global configuration accessors. **Example**: Add a getter to check if the app is running in a specific mode. ```ts import app from '@adonisjs/core/services/app' app.getter('isProduction', function () { return this.inProduction }) ``` [View source](https://github.com/adonisjs/application/blob/9.x/src/application.ts) ::: :::option{name="HttpRequest" import="@adonisjs/core/http"} The HTTP request class. Extend this to add request validation or parsing logic. **Common use cases**: Add methods for checking request characteristics, parsing custom headers, or validating request types. **Example**: Add a method to check if the request is an AJAX request. ```ts import { HttpRequest } from '@adonisjs/core/http' HttpRequest.macro('isAjax', function (this: HttpRequest) { return this.header('x-requested-with') === 'XMLHttpRequest' }) ``` [View source](https://github.com/adonisjs/http-server/blob/8.x/src/request.ts) ::: :::option{name="HttpResponse" import="@adonisjs/core/http"} The HTTP response class. Extend this to add custom response methods or formatters. **Common use cases**: Add methods for sending formatted responses, setting common headers, or handling specific response types. **Example**: Add a method for sending paginated JSON responses. ```ts import { HttpResponse } from '@adonisjs/core/http' HttpResponse.macro('paginated', function (this: HttpResponse, data: any, meta: any) { return this.json({ data, meta }) }) ``` [View source](https://github.com/adonisjs/http-server/blob/8.x/src/response.ts) ::: :::option{name="HttpContext" import="@adonisjs/core/http"} The HTTP context class passed to route handlers and middleware. Extend this to add context-level utilities. **Common use cases**: Add helpers that combine request and response logic, or add shortcuts for common operations. **Example**: Add a method to get the current user or fail. ```ts import { HttpContext } from '@adonisjs/core/http' HttpContext.macro('getCurrentUser', async function (this: HttpContext) { return await this.auth.getUserOrFail() }) ``` [View source](https://github.com/adonisjs/http-server/blob/8.x/src/http_context/main.ts) ::: :::option{name="Route" import="@adonisjs/core/http"} Individual route instances. Extend this to add custom route configuration methods. **Common use cases**: Add methods for applying common middleware patterns, setting route metadata, or configuring routes in specific ways. **Example**: Add a method to mark routes as requiring authentication. ```ts import { Route } from '@adonisjs/core/http' Route.macro('protected', function (this: Route) { return this.middleware('auth') }) ``` [View source](https://github.com/adonisjs/http-server/blob/8.x/src/router/route.ts) ::: :::option{name="RouteGroup" import="@adonisjs/core/http"} Route group instances. Extend this to add custom group-level configuration. **Common use cases**: Add methods for applying common patterns to groups of routes. **Example**: Add a method to apply API versioning to a group. ```ts import { RouteGroup } from '@adonisjs/core/http' RouteGroup.macro('apiVersion', function (this: RouteGroup, version: number) { return this.prefix(`/api/v${version}`) }) ``` [View source](https://github.com/adonisjs/http-server/blob/8.x/src/router/group.ts) ::: :::option{name="RouteResource" import="@adonisjs/core/http"} Resourceful route instances. Extend this to customize resource route behavior. **Common use cases**: Add methods for customizing which resource routes are created or adding resource-specific middleware. [View source](https://github.com/adonisjs/http-server/blob/8.x/src/router/resource.ts) ::: :::option{name="BriskRoute" import="@adonisjs/core/http"} Brisk (quick) route instances used for simple route definitions. Extend this for shortcuts. **Common use cases**: Add convenience methods for quick route configurations. [View source](https://github.com/adonisjs/http-server/blob/8.x/src/router/brisk.ts) ::: :::option{name="ExceptionHandler" import="@adonisjs/core/http"} The global exception handler. Extend this to add custom error handling methods. **Common use cases**: Add methods for handling specific error types or formatting error responses. **Example**: Add a method to handle validation errors consistently. ```ts import { ExceptionHandler } from '@adonisjs/core/http' ExceptionHandler.macro('handleValidationError', function (error: any) { return this.ctx.response.status(422).json({ errors: error.messages }) }) ``` [View source](https://github.com/adonisjs/http-server/blob/8.x/src/exception_handler.ts) ::: :::option{name="MultipartFile" import="@adonisjs/core/bodyparser"} Uploaded file instances. Extend this to add file validation or processing methods. **Common use cases**: Add methods for validating file types, processing images, or generating thumbnails. **Example**: Add a method to check if a file is an image. ```ts import { MultipartFile } from '@adonisjs/core/bodyparser' MultipartFile.macro('isImage', function (this: MultipartFile) { return this.type?.startsWith('image/') }) ``` [View source](https://github.com/adonisjs/bodyparser/blob/11.x/src/multipart/file.ts) ::: :::: ## Extending specific modules Beyond macros and getters, many AdonisJS modules provide dedicated extension APIs for adding custom implementations. These are designed for more complex integrations like custom drivers or loaders. The following modules can be extended with custom implementations: - [Creating a custom hash driver](../security/hashing.md#creating-a-custom-hash-driver) - Add support for custom password hashing algorithms - [Creating a custom session store](../basics/session.md#creating-a-custom-session-store) - Store sessions in custom backends like MongoDB or Redis - [Creating a custom social auth driver](../auth/social_authentication.md#creating-a-custom-social-driver) - Add OAuth providers beyond the built-in ones - [Adding custom REPL methods](../ace/repl.md#adding-custom-methods-to-repl) - Extend the REPL with custom commands - [Creating a custom translations loader](../digging_deeper/i18n.md#creating-a-custom-translation-loader) - Load translations from custom sources - [Creating a custom translations formatter](../digging_deeper/i18n.md#creating-a-custom-translation-formatter) - Format translations with custom logic These extension points go beyond simple methods and properties, allowing you to deeply integrate custom functionality into the framework. ## Next steps Now that you understand how to extend the framework, you can: - Learn about [Service Providers](./service_providers.md) to organize extension code in packages - Explore [Dependency Injection](./dependency_injection.md) to understand how the container works - Read about [Testing](../testing/introduction.md) to learn how to test your extensions - Study the [first-party packages](https://github.com/adonisjs) to see real-world extension examples --- # Cache This guide covers caching in AdonisJS applications. You will learn how to: - Configure cache stores with different drivers (Redis, Memory, Database, DynamoDB) - Store, retrieve, and invalidate cached data - Use multi-tier caching with L1 (memory) and L2 (distributed) layers - Organize cache entries with namespaces and tags - Improve resilience with grace periods, stampede protection, and timeouts - Use Ace commands to manage your cache ## Overview The `@adonisjs/cache` package provides a unified caching API for your AdonisJS application. Built on top of [Bentocache](https://bentocache.dev), it goes beyond simple key-value storage by offering multi-tier caching, cache stampede protection, grace periods, and more. The package introduces two key concepts. A **driver** is the underlying storage mechanism (Redis, in-memory, database). A **store** is a configured caching layer that combines one or more drivers. You can configure multiple stores in your application, each with different drivers and settings, and switch between them at runtime. Multi-tier caching is the standout feature. By combining an in-memory L1 cache with a distributed L2 cache (like Redis), you get the speed of local memory with the persistence and scalability of a shared cache. This setup can deliver responses between 2,000x and 5,000x faster compared to single-tier approaches. ## Installation Install and configure the package using the following command: ```sh node ace add @adonisjs/cache ``` :::disclosure{title="See steps performed by the add command"} 1. Installs the `@adonisjs/cache` package using the detected package manager. 2. Registers the following service provider and command inside the `adonisrc.ts` file. ```ts title="adonisrc.ts" { commands: [ // ...other commands () => import('@adonisjs/cache/commands') ], providers: [ // ...other providers () => import('@adonisjs/cache/cache_provider') ] } ``` 3. Creates the `config/cache.ts` file. 4. Defines the environment variables and their validations for the selected drivers. ::: ## Configuration The cache configuration lives in `config/cache.ts`. This file defines your stores, the default store, and driver-specific settings. See also: [Config stub](https://github.com/adonisjs/cache/blob/2.x/stubs/config.stub) ```ts title="config/cache.ts" import { defineConfig, store, drivers } from '@adonisjs/cache' const cacheConfig = defineConfig({ /** * The store to use when none is specified */ default: 'redis', /** * Default TTL for all cached entries. * Can be overridden per-store or per-operation. */ ttl: '30s', /** * Configure one or more stores. Each store defines * its caching layers and driver settings. */ stores: { /** * A multi-tier store combining in-memory speed * with Redis persistence and cross-instance sync. */ redis: store() .useL1Layer(drivers.memory({ maxSize: '100mb' })) .useL2Layer(drivers.redis({ connectionName: 'main' })) .useBus(drivers.redisBus({ connectionName: 'main' })), /** * A simple in-memory store for single-instance apps */ memory: store() .useL1Layer(drivers.memory({ maxSize: '100mb' })), /** * A database-backed store using your Lucid connection */ database: store() .useL2Layer(drivers.database({ connectionName: 'default' })), }, }) export default cacheConfig ``` ### Available drivers :::disclosure{title="Redis"} Uses Redis as a distributed cache. Requires the `@adonisjs/redis` package to be installed and configured. Compatible with Redis, Upstash, Vercel KV, Valkey, KeyDB, and DragonFly. ```ts title="config/cache.ts" { stores: { redis: store() .useL2Layer(drivers.redis({ connectionName: 'main', })) } } ``` See also: [Redis setup guide](../database/redis.md) ::: :::disclosure{title="Memory"} Uses an in-memory LRU (Least Recently Used) cache. Best suited as an L1 layer in a multi-tier setup or for single-instance applications. ```ts title="config/cache.ts" { stores: { memory: store() .useL1Layer(drivers.memory({ maxSize: '100mb', maxItems: 1000, })) } } ``` ::: :::disclosure{title="Database"} Uses your database as a cache store. Requires `@adonisjs/lucid`. The cache table is created automatically by default. ```ts title="config/cache.ts" { stores: { database: store() .useL2Layer(drivers.database({ connectionName: 'default', tableName: 'cache', autoCreateTable: true, })) } } ``` ::: :::disclosure{title="DynamoDB"} Uses AWS DynamoDB as a cache store. Requires `@aws-sdk/client-dynamodb`. You must create the table beforehand with a string partition key named `key` and TTL enabled on the `ttl` attribute. ```sh npm i @aws-sdk/client-dynamodb ``` ```ts title="config/cache.ts" { stores: { dynamo: store() .useL2Layer(drivers.dynamodb({ table: { name: 'cache' }, region: 'us-east-1', credentials: { accessKeyId: env.get('AWS_ACCESS_KEY_ID'), secretAccessKey: env.get('AWS_SECRET_ACCESS_KEY'), }, })) } } ``` ::: ## Storing and retrieving data Import the cache service to interact with your cache. All cache operations are available through the `cache` object. ```ts title="app/controllers/posts_controller.ts" import cache from '@adonisjs/cache/services/main' ``` ### Getting and setting values The most common pattern is `getOrSet`. It tries to find a value in the cache and, if missing, executes the factory function to compute the value, stores it, and returns it. ```ts title="app/controllers/posts_controller.ts" import type { HttpContext } from '@adonisjs/core/http' import cache from '@adonisjs/cache/services/main' import Post from '#models/post' export default class PostsController { async index({ request }: HttpContext) { const page = request.input('page', 1) const posts = await cache.getOrSet({ key: `posts:page:${page}`, ttl: '10m', factory: () => Post.query().paginate(page, 20), }) return posts } } ``` You can also use `get` and `set` independently when you need more control over the flow. ```ts title="app/services/settings_service.ts" import cache from '@adonisjs/cache/services/main' /** * Store a value with a 5-minute TTL */ await cache.set({ key: 'app:settings', value: { maintenance: false, theme: 'dark' }, ttl: '5m', }) /** * Retrieve a value. Returns undefined if the key * does not exist. */ const settings = await cache.get({ key: 'app:settings' }) /** * Store a value that never expires */ await cache.setForever({ key: 'app:version', value: '2.0.0', }) ``` :::warning Cached data must be serializable to JSON. If you are caching Lucid models, call `.toJSON()` or `.serialize()` before storing them, or use `getOrSet` which handles serialization automatically. ::: ### Checking for existence Use `has` and `missing` to check whether a key exists in the cache without retrieving its value. ```ts title="app/controllers/products_controller.ts" import cache from '@adonisjs/cache/services/main' if (await cache.has({ key: 'products:featured' })) { // Key exists in cache } if (await cache.missing({ key: 'products:featured' })) { // Key does not exist } ``` ### Pulling values The `pull` method retrieves a value and immediately deletes it from the cache. This is useful for one-time-use data like flash messages or temporary tokens. ```ts title="app/controllers/auth_controller.ts" import cache from '@adonisjs/cache/services/main' /** * Get the token and remove it from cache in one operation */ const token = await cache.pull({ key: `email-verify:${userId}` }) ``` ## Deleting data Remove individual entries with `delete`, multiple entries with `deleteMany`, or all entries with `clear`. ```ts title="app/controllers/posts_controller.ts" import cache from '@adonisjs/cache/services/main' /** * Delete a single key */ await cache.delete({ key: 'posts:page:1' }) /** * Delete multiple keys at once */ await cache.deleteMany({ keys: ['posts:page:1', 'posts:page:2', 'posts:page:3'], }) /** * Delete all entries in the cache */ await cache.clear() ``` ## Tagging Tags let you group related cache entries so you can invalidate them together. This is especially useful when a change affects multiple cached values. For example, when a post is updated, you can invalidate all pages that might display it. ```ts title="app/controllers/posts_controller.ts" import cache from '@adonisjs/cache/services/main' import Post from '#models/post' export default class PostsController { async index({ request }: HttpContext) { const page = request.input('page', 1) /** * Tag the cached page with "posts" so we can * invalidate all pages when any post changes. */ const posts = await cache.getOrSet({ key: `posts:page:${page}`, ttl: '10m', tags: ['posts'], factory: () => Post.query().paginate(page, 20), }) return posts } async update({ params, request }: HttpContext) { const post = await Post.findOrFail(params.id) post.merge(request.all()) await post.save() /** * Invalidate all cache entries tagged with "posts". * Every paginated page will be refreshed on next request. */ await cache.deleteByTag({ tags: ['posts'] }) return post } } ``` You can assign multiple tags to a single entry. An entry is invalidated when any of its tags is invalidated. ```ts title="app/services/dashboard_service.ts" import cache from '@adonisjs/cache/services/main' await cache.getOrSet({ key: `dashboard:user:${userId}`, ttl: '5m', tags: ['dashboard', `user:${userId}`], factory: () => buildDashboard(userId), }) /** * Invalidate a specific user's dashboard */ await cache.deleteByTag({ tags: [`user:${userId}`] }) /** * Or invalidate all dashboards */ await cache.deleteByTag({ tags: ['dashboard'] }) ``` :::tip Avoid using too many tags per entry. BentoCache uses client-side tagging, which means each retrieval checks tag invalidation timestamps. A large number of tags per entry can slow down lookups. ::: ## Namespaces Namespaces group cache keys under a common prefix, allowing you to clear all entries in a namespace without affecting the rest of your cache. ```ts title="app/services/user_service.ts" import cache from '@adonisjs/cache/services/main' const usersCache = cache.namespace('users') /** * Keys are automatically prefixed with "users:" * This stores the value under "users:42" */ await usersCache.set({ key: '42', value: { name: 'John' } }) await usersCache.set({ key: '43', value: { name: 'Jane' } }) /** * Retrieve from the namespace */ const user = await usersCache.get({ key: '42' }) /** * Clear only the "users" namespace. * Other cache entries remain untouched. */ await usersCache.clear() ``` ## Switching stores When you configure multiple stores, you can switch between them using the `use` method. Without it, the default store is used. ```ts title="app/controllers/products_controller.ts" import cache from '@adonisjs/cache/services/main' /** * Use the default store */ await cache.getOrSet({ key: 'products:featured', factory: () => Product.query().where('featured', true).exec(), }) /** * Use the "memory" store for short-lived data */ await cache.use('memory').set({ key: 'rate-limit:user:42', value: 1, ttl: '1m', }) /** * Use the "database" store for long-lived data */ await cache.use('database').setForever({ key: 'site:config', value: { theme: 'dark' }, }) ``` ## Multi-tier caching Multi-tier caching combines a fast in-memory L1 cache with a persistent distributed L2 cache. This is the recommended setup for production applications running multiple instances. ### How it works When you read a value, the cache checks the L1 (in-memory) layer first. If the value is found, it returns immediately without any network call. If missing, it fetches from the L2 (Redis) layer, stores a copy in L1, and returns it. When you write or delete a value, both layers are updated. A **bus** notifies other application instances to evict their stale L1 entries, so every instance stays consistent. ### Configuration Combine `useL1Layer`, `useL2Layer`, and `useBus` to create a multi-tier store. ```ts title="config/cache.ts" import { defineConfig, store, drivers } from '@adonisjs/cache' const cacheConfig = defineConfig({ default: 'multitier', stores: { multitier: store() .useL1Layer(drivers.memory({ maxSize: '100mb' })) .useL2Layer(drivers.redis({ connectionName: 'main' })) .useBus(drivers.redisBus({ connectionName: 'main' })), }, }) export default cacheConfig ``` :::tip If your application runs on a single instance, you can omit the bus. The bus is only necessary when multiple instances need to synchronize their L1 caches. ::: The bus sends only invalidation messages (not the actual values) to other instances. When an instance receives an invalidation message, it removes the key from its L1 cache. The next read will fetch the updated value from L2. ## Grace periods Grace periods allow you to serve slightly stale cached data while refreshing the value in the background. This makes your application resilient to temporary outages in your data source (database downtime, API failures). When a cached entry expires but remains within its grace period, the cache returns the stale value to the caller and triggers a background refresh. If the refresh fails (because the database is down, for example), the stale value continues to be served instead of returning an error. ```ts title="app/controllers/products_controller.ts" import cache from '@adonisjs/cache/services/main' import Product from '#models/product' const products = await cache.getOrSet({ key: 'products:featured', /** * Data is "fresh" for 10 minutes */ ttl: '10m', /** * After expiring, stale data remains available for 6 hours. * If the factory fails during this window, the stale * value is returned instead of an error. */ grace: '6h', factory: () => Product.query().where('featured', true).exec(), }) ``` ### Backoff strategy When a factory call fails during the grace period, you probably do not want to retry on every subsequent request. The `graceBackoff` option sets a delay between retry attempts. ```ts title="app/controllers/products_controller.ts" const products = await cache.getOrSet({ key: 'products:featured', ttl: '10m', grace: '6h', /** * After a failed refresh, wait 5 minutes before * trying again. Stale data is served in the meantime. */ graceBackoff: '5m', factory: () => Product.query().where('featured', true).exec(), }) ``` You can also enable grace periods globally in your configuration so you do not have to repeat them on every operation. ```ts title="config/cache.ts" const cacheConfig = defineConfig({ default: 'redis', ttl: '10m', grace: '6h', graceBackoff: '30s', stores: { // ... }, }) ``` ## Stampede protection A **cache stampede** occurs when a cache entry expires and many concurrent requests all try to regenerate it at the same time, overwhelming your data source. BentoCache prevents this automatically. When the first request finds a missing or expired key, it acquires a lock and executes the factory function. All other concurrent requests for the same key wait for the lock to release and then receive the cached result. This means only one factory execution happens, regardless of how many requests arrive simultaneously. For example, if 10,000 requests hit an expired key at the same time, only one database query is made. The other 9,999 requests receive the cached result once it is available. This protection is built in and requires no configuration. ## Timeouts Timeouts prevent slow factory functions from blocking your responses. BentoCache supports two types. ### Soft timeouts A soft timeout works alongside grace periods. If the factory takes longer than the timeout and a stale entry exists in the grace window, the stale value is returned immediately while the factory continues running in the background. ```ts title="app/controllers/products_controller.ts" const products = await cache.getOrSet({ key: 'products:featured', ttl: '10m', grace: '6h', /** * If the factory takes more than 200ms, return the * stale value immediately. The factory keeps running * in the background to update the cache. */ timeout: '200ms', factory: () => Product.query().where('featured', true).exec(), }) ``` :::note Soft timeouts only take effect when a stale entry is available in the grace period. If no stale entry exists (first-time cache population), the request waits for the factory to complete. ::: ### Hard timeouts A hard timeout sets an absolute limit on how long to wait for the factory. If exceeded, an exception is thrown. The factory continues executing in the background so the cache will be populated for subsequent requests. ```ts title="app/controllers/products_controller.ts" const products = await cache.getOrSet({ key: 'products:featured', ttl: '10m', /** * If the factory takes more than 1 second, * throw an error. */ hardTimeout: '1s', factory: () => Product.query().where('featured', true).exec(), }) ``` You can combine both timeouts. The soft timeout returns stale data quickly, and the hard timeout acts as a safety net. ```ts title="app/controllers/products_controller.ts" const products = await cache.getOrSet({ key: 'products:featured', ttl: '10m', grace: '6h', timeout: '200ms', hardTimeout: '1s', factory: () => Product.query().where('featured', true).exec(), }) ``` ## Adaptive caching Adaptive caching lets you dynamically adjust cache options based on the data being cached. This is useful when the ideal TTL depends on the actual value. The factory function receives a context object with helper methods to adjust cache behavior after inspecting the fetched data. ```ts title="app/services/auth_service.ts" import cache from '@adonisjs/cache/services/main' const token = await cache.getOrSet({ key: `auth:token:${provider}`, ttl: '1h', factory: async (ctx) => { const token = await fetchOAuthToken(provider) /** * Set the TTL based on the token's actual expiration * rather than using a fixed value */ ctx.setOptions({ ttl: `${token.expiresIn}s` }) return token }, }) ``` The context object also provides: - `ctx.skip()` to prevent caching the returned value - `ctx.fail()` to prevent caching and throw an error - `ctx.setTags([...])` to dynamically set tags based on the cached value - `ctx.gracedEntry` to access the stale value from the grace period (if any) ## Edge integration The cache service is available in your Edge templates. You can use it to display cached values directly in your views. ```edge title="resources/views/pages/home.edge"

Hello {{ await cache.get({ key: 'username' }) }}

``` ## Ace commands The `@adonisjs/cache` package provides Ace commands for managing your cache from the terminal. ### cache:clear Remove all entries from a store, namespace, or tag. ```sh # Clear the default store node ace cache:clear # Clear a specific store node ace cache:clear redis # Clear a specific namespace node ace cache:clear --namespace=users # Clear entries matching specific tags node ace cache:clear --tags=products --tags=users ``` ### cache:delete Remove a specific key from the cache. ```sh # Delete from the default store node ace cache:delete posts:page:1 # Delete from a specific store node ace cache:delete posts:page:1 redis ``` ### cache:prune Remove expired entries from drivers that do not support automatic TTL expiration (like the database and filesystem drivers). Redis handles expiration natively and does not need pruning. ```sh # Prune the default store node ace cache:prune # Prune a specific store node ace cache:prune database ``` ## Method reference All methods are available on the `cache` object imported from `@adonisjs/cache/services/main`. They are also available on instances returned by `cache.use()` and `cache.namespace()`. ::::options :::option{name="getOrSet" dataType="Promise"} Try to get a value from cache. If missing, execute the factory, cache the result, and return it. ```ts await cache.getOrSet({ key: 'users:1', ttl: '10m', grace: '6h', tags: ['users'], factory: () => User.find(1), }) ``` ::: :::option{name="get" dataType="Promise"} Retrieve a value from the cache. Returns `undefined` if the key does not exist. ```ts const user = await cache.get({ key: 'users:1' }) ``` ::: :::option{name="set" dataType="Promise"} Store a value in the cache with an optional TTL. ```ts await cache.set({ key: 'users:1', value: user, ttl: '10m' }) ``` ::: :::option{name="setForever" dataType="Promise"} Store a value in the cache that never expires. ```ts await cache.setForever({ key: 'app:version', value: '2.0.0' }) ``` ::: :::option{name="has" dataType="Promise"} Check if a key exists in the cache. ```ts const exists = await cache.has({ key: 'users:1' }) ``` ::: :::option{name="missing" dataType="Promise"} Check if a key does not exist in the cache. ```ts const notCached = await cache.missing({ key: 'users:1' }) ``` ::: :::option{name="pull" dataType="Promise"} Retrieve a value from the cache and delete it immediately. ```ts const token = await cache.pull({ key: 'verify:token:123' }) ``` ::: :::option{name="delete" dataType="Promise"} Remove a single key from the cache. ```ts await cache.delete({ key: 'users:1' }) ``` ::: :::option{name="deleteMany" dataType="Promise"} Remove multiple keys from the cache. ```ts await cache.deleteMany({ keys: ['users:1', 'users:2'] }) ``` ::: :::option{name="deleteByTag" dataType="Promise"} Remove all entries associated with the given tags. ```ts await cache.deleteByTag({ tags: ['users'] }) ``` ::: :::option{name="clear" dataType="Promise"} Remove all entries from the cache (or from the current namespace). ```ts await cache.clear() ``` ::: :::option{name="namespace" dataType="CacheNamespace"} Return a namespace instance. All operations on the returned instance are scoped to the namespace. ```ts const usersCache = cache.namespace('users') await usersCache.set({ key: '1', value: user }) ``` ::: :::option{name="use" dataType="CacheStore"} Return a specific store instance by name. ```ts const redisCache = cache.use('redis') await redisCache.get({ key: 'users:1' }) ``` ::: :::: --- # Drive This guide covers file storage management in AdonisJS using Drive. You will learn how to: - Install and configure Drive for your application - Upload files to local or cloud storage using `moveToDisk` - Display files using public URLs and signed URLs - Configure multiple storage services (S3, GCS, R2, DigitalOcean Spaces, Supabase) - Implement direct uploads from the browser to cloud storage - Test file uploads using the Drive fakes API ## Overview AdonisJS Drive is a wrapper on top of [FlyDrive](https://flydrive.dev) (created and maintained by the AdonisJS core team). It provides a unified API for managing user-uploaded files across multiple storage providers, including the local filesystem, Amazon S3, Google Cloud Storage, Cloudflare R2, DigitalOcean Spaces, and Supabase Storage. The key benefit of Drive is that you can switch between storage services without changing your application code. During development, you might store files on the local filesystem for convenience. In production, you switch to a cloud provider by changing an environment variable. Your controllers, services, and templates remain unchanged. :::note Drive handles file storage operations like reading, writing, and deleting files. It does not handle HTTP multipart parsing. You should read the [file uploads guide](../basics/file_uploads.md) first to understand how AdonisJS processes uploaded files from HTTP requests. ::: ## Installation Install and configure the `@adonisjs/drive` package using the following command: ```sh node ace add @adonisjs/drive ``` The command prompts you to select one or more storage services. :::disclosure{title="Steps performed by the add command"} 1. Installs the `@adonisjs/drive` package and any required peer dependencies for your selected services. 2. Registers the Drive service provider in `adonisrc.ts`. 3. Creates the `config/drive.ts` configuration file with your selected services. 4. Adds environment variables for your selected services to `.env` and `start/env.ts`. ::: :::tip{title="Non-interactive installation"} The `node ace add` command requires interactive service selection. If you need to install Drive non-interactively (for example, in CI scripts), you can perform the steps manually: 1. Install the package: `npm install @adonisjs/drive` 2. Install peer dependencies for your storage service (e.g., `npm install @aws-sdk/client-s3 @aws-sdk/s3-request-presigner` for S3) 3. Register the provider in `adonisrc.ts`: add `() => import('@adonisjs/drive/drive_provider')` to the `providers` array 4. Create `config/drive.ts` with your service configuration (see the [Configuration](#configuration) section below) 5. Add the required environment variables to `.env` and `start/env.ts` ::: ## Configuration The configuration for Drive is stored in `config/drive.ts`. The file contents depend on which services you selected during installation. The `default` property in the config file determines which service is used when you don't explicitly specify one. The `DRIVE_DISK` environment variable controls this, allowing you to use `fs` locally and switch to `s3` in production. ```ts title="config/drive.ts" import env from '#start/env' import app from '@adonisjs/core/services/app' import { defineConfig, services } from '@adonisjs/drive' const driveConfig = defineConfig({ default: env.get('DRIVE_DISK'), services: { // Service configurations go here }, }) export default driveConfig declare module '@adonisjs/drive/types' { export interface DriveDisks extends InferDriveDisks {} } ``` ### Local filesystem The local filesystem driver stores files on your server's disk and can serve them via the AdonisJS HTTP server. :::disclosure{title="Environment variables"} ```dotenv title=".env" DRIVE_DISK=fs ``` ::: :::disclosure{title="Configuration"} ```ts title="config/drive.ts" { services: { fs: services.fs({ /** * The directory where files are stored. Use app.makePath * to create an absolute path from your application root. */ location: app.makePath('storage'), /** * When true, Drive registers a route to serve files * from the local filesystem via your AdonisJS server. */ serveFiles: true, /** * The URL path prefix for serving files. A file stored * as "avatars/1.jpg" becomes accessible at "/uploads/avatars/1.jpg". */ routeBasePath: '/uploads', /** * The default visibility for files. Public files are * accessible via URL. Private files require signed URLs. */ visibility: 'public', }), } } ``` ::: :::tip When `serveFiles` is enabled, you can verify the route is registered by running `node ace list:routes`. You should see a route like `/uploads/*` with the handler `drive.fs.serve`. ::: ### Amazon S3 :::disclosure{title="Environment variables"} ```dotenv title=".env" DRIVE_DISK=s3 AWS_ACCESS_KEY_ID=your_access_key AWS_SECRET_ACCESS_KEY=your_secret_key AWS_REGION=us-east-1 S3_BUCKET=your_bucket_name ``` ::: :::disclosure{title="Configuration"} ```ts title="config/drive.ts" { services: { s3: services.s3({ credentials: { accessKeyId: env.get('AWS_ACCESS_KEY_ID'), secretAccessKey: env.get('AWS_SECRET_ACCESS_KEY'), }, region: env.get('AWS_REGION'), bucket: env.get('S3_BUCKET'), visibility: 'public', }), } } ``` ::: ### Google Cloud Storage :::disclosure{title="Environment variables"} ```dotenv title=".env" DRIVE_DISK=gcs GCS_KEY=file://gcs_key.json GCS_BUCKET=your_bucket_name ``` The `GCS_KEY` variable points to a JSON key file for your Google Cloud service account. The `file://` prefix indicates the path is relative to your application root. ::: :::disclosure{title="Configuration"} ```ts title="config/drive.ts" { services: { gcs: services.gcs({ credentials: env.get('GCS_KEY'), bucket: env.get('GCS_BUCKET'), visibility: 'public', }), } } ``` ::: ### Cloudflare R2 Cloudflare R2 uses the S3-compatible API. The `region` must be set to `'auto'`. :::disclosure{title="Environment variables"} ```dotenv title=".env" DRIVE_DISK=r2 R2_KEY=your_access_key R2_SECRET=your_secret_key R2_BUCKET=your_bucket_name R2_ENDPOINT=https://your_account_id.r2.cloudflarestorage.com ``` ::: :::disclosure{title="Configuration"} ```ts title="config/drive.ts" { services: { r2: services.s3({ credentials: { accessKeyId: env.get('R2_KEY'), secretAccessKey: env.get('R2_SECRET'), }, region: 'auto', bucket: env.get('R2_BUCKET'), endpoint: env.get('R2_ENDPOINT'), visibility: 'public', }), } } ``` ::: ### DigitalOcean Spaces DigitalOcean Spaces uses the S3-compatible API with a custom endpoint. :::disclosure{title="Environment variables"} ```dotenv title=".env" DRIVE_DISK=spaces SPACES_KEY=your_access_key SPACES_SECRET=your_secret_key SPACES_REGION=nyc3 SPACES_BUCKET=your_bucket_name SPACES_ENDPOINT=https://${SPACES_REGION}.digitaloceanspaces.com ``` ::: :::disclosure{title="Configuration"} ```ts title="config/drive.ts" { services: { spaces: services.s3({ credentials: { accessKeyId: env.get('SPACES_KEY'), secretAccessKey: env.get('SPACES_SECRET'), }, region: env.get('SPACES_REGION'), bucket: env.get('SPACES_BUCKET'), endpoint: env.get('SPACES_ENDPOINT'), visibility: 'public', }), } } ``` ::: ### Supabase Storage Supabase Storage uses the S3-compatible API. :::disclosure{title="Environment variables"} ```dotenv title=".env" DRIVE_DISK=supabase SUPABASE_STORAGE_KEY=your_access_key SUPABASE_STORAGE_SECRET=your_secret_key SUPABASE_STORAGE_REGION=your_region SUPABASE_STORAGE_BUCKET=your_bucket_name SUPABASE_ENDPOINT=https://your_project.supabase.co/storage/v1/s3 ``` ::: :::disclosure{title="Configuration"} ```ts title="config/drive.ts" { services: { supabase: services.s3({ credentials: { accessKeyId: env.get('SUPABASE_STORAGE_KEY'), secretAccessKey: env.get('SUPABASE_STORAGE_SECRET'), }, region: env.get('SUPABASE_STORAGE_REGION'), bucket: env.get('SUPABASE_STORAGE_BUCKET'), endpoint: env.get('SUPABASE_ENDPOINT'), visibility: 'public', }), } } ``` ::: ## Basic usage Drive extends the AdonisJS `MultipartFile` class and adds the `moveToDisk` method. This method moves an uploaded file from its temporary location to your configured storage service. The following example shows a complete flow for uploading and displaying a user avatar. ::::steps :::step{title="Define routes"} Create routes for displaying the profile page and handling avatar uploads. ```ts title="start/routes.ts" import router from '@adonisjs/core/services/router' import { controllers } from '#generated/controllers' router.get('profile', [controllers.Profile, 'show']) router.post('profile/avatar', [controllers.Profile, 'updateAvatar']) ``` ::: :::step{title="Create the controller"} The controller handles the file upload using `moveToDisk`. Generate a unique filename using a UUID to avoid collisions when multiple users upload files with the same name. ```ts title="app/controllers/profile_controller.ts" import string from '@adonisjs/core/helpers/string' import type { HttpContext } from '@adonisjs/core/http' import { updateAvatarValidator } from '#validators/user' export default class ProfileController { async show({ view, auth }: HttpContext) { const user = auth.getUserOrFail() return view.render('pages/profile/show', { user }) } async updateAvatar({ request, auth, response, session }: HttpContext) { const user = auth.getUserOrFail() const { avatar } = await request.validateUsing(updateAvatarValidator) /** * Generate a unique filename using a UUID to avoid * collisions when multiple users upload files with * the same name. */ const key = `${string.uuid()}.${avatar.extname ?? 'txt'}` /** * Move the uploaded file to the default disk. * The file is moved from its temporary location * to your configured storage service. */ await avatar.moveToDisk(key) /** * Store only the key in the database, not the full URL. * This allows you to switch storage services without * updating database records. */ user.avatar = key await user.save() session.flash('success', 'Avatar updated successfully!') return response.redirect().back() } } ``` ::: :::step{title="Create the template"} The template displays the current avatar using the `driveUrl` helper and provides a form for uploading a new one. ```edge title="resources/views/pages/profile/show.edge" @layout()
@if(user.avatar) // [!code highlight:5] User avatar @end @form({ route: 'profile.update_avatar', method: 'POST', enctype: 'multipart/form-data' })
@field.root({ name: 'avatar' }) @!input.control({ type: 'file' }) @!field.error() @end
@!button({ type: 'submit', text: 'Update avatar' })
@end
@end ``` The `driveUrl` Edge helper generates the public URL for a file. For the local filesystem, this returns a path like `/uploads/abc-123.jpg`. For cloud providers, it returns the full URL to the file. ::: :::: ### Specifying a disk By default, `moveToDisk` uses the disk specified in the `DRIVE_DISK` environment variable. You can explicitly specify a different disk as the second argument: ```ts title="app/controllers/profile_controller.ts" // Move to the default disk await avatar.moveToDisk(key) // Move to a specific disk await avatar.moveToDisk(key, 's3') await avatar.moveToDisk(key, 'gcs') await avatar.moveToDisk(key, 'r2') ``` :::warning The `moveToDisk` method is different from the `move` method. The `move` method moves files within the local filesystem only. The `moveToDisk` method moves files to your configured Drive storage service, which could be local or cloud-based. ::: ## Using the Drive service For operations beyond file uploads, you can use the Drive service directly. Import it from `@adonisjs/drive/services/main` to read files, write files, delete files, and more. ```ts title="app/services/file_service.ts" import drive from '@adonisjs/drive/services/main' export class FileService { /** * Get the default disk instance */ async readFile(key: string) { const disk = drive.use() return disk.get(key) } /** * Get a specific disk instance */ async readFromS3(key: string) { const disk = drive.use('s3') return disk.get(key) } /** * Write content directly to storage */ async writeReport(content: string) { const disk = drive.use() await disk.put('reports/monthly.txt', content) } /** * Delete a file */ async deleteFile(key: string) { const disk = drive.use() await disk.delete(key) } /** * Check if a file exists */ async fileExists(key: string) { const disk = drive.use() return disk.exists(key) } } ``` The Drive service provides many more methods for file operations. See the [FlyDrive Disk API documentation](https://flydrive.dev/docs/disk_api) for the complete list of available methods. ## Generating URLs Drive provides two methods for generating file URLs: `getUrl` for public files and `getSignedUrl` for private files. ### Public URLs Use `getUrl` to generate URLs for files with `public` visibility. Anyone with the URL can access these files. ```ts title="app/controllers/files_controller.ts" import drive from '@adonisjs/drive/services/main' export default class FilesController { async show({ params }: HttpContext) { const disk = drive.use() const url = await disk.getUrl(params.key) return { url } } } ``` In Edge templates, use the `driveUrl` helper: ```edge title="resources/views/files/show.edge" File {{-- Specify a disk --}} File ``` ### Signed URLs Use `getSignedUrl` to generate temporary URLs for files with `private` visibility. These URLs expire after a specified duration. Signed URLs are useful when you want to control access to files. For example, you might store invoices with private visibility and generate a signed URL only when an authorized user requests to download one. ```ts title="app/controllers/invoices_controller.ts" import drive from '@adonisjs/drive/services/main' export default class InvoicesController { async download({ params, auth }: HttpContext) { const user = auth.getUserOrFail() const invoice = await Invoice.query() .where('id', params.id) .where('userId', user.id) .firstOrFail() const disk = drive.use() /** * Generate a signed URL that expires in 30 minutes. * The user can only download the file while the URL is valid. */ const url = await disk.getSignedUrl(invoice.fileKey, { expiresIn: '30 mins', }) return { url } } } ``` In Edge templates, use the `driveSignedUrl` helper: ```edge title="resources/views/invoices/show.edge" Download Invoice {{-- With expiration --}} Download Invoice {{-- Specify a disk --}} Download Invoice ``` ## Direct uploads Direct uploads allow the browser to upload files directly to your cloud storage provider, bypassing your AdonisJS server. This is useful for large files because the data doesn't flow through your server, reducing memory usage and bandwidth costs. The flow works as follows: 1. The browser requests a signed upload URL from your server. 2. Your server generates a signed URL using Drive and returns it. 3. The browser uploads the file directly to the cloud provider using the signed URL. 4. The browser notifies your server that the upload is complete. ::::steps :::step{title="Define routes"} Create routes for generating signed upload URLs and handling upload completion. ```ts title="start/routes.ts" import router from '@adonisjs/core/services/router' import { controllers } from '#generated/controllers' router.post('uploads/presign', [controllers.Uploads, 'presign']) router.post('uploads/complete', [controllers.Uploads, 'complete']) ``` ::: :::step{title="Create the controller"} The controller generates signed upload URLs using `getSignedUploadUrl` and handles upload completion notifications. ```ts title="app/controllers/uploads_controller.ts" import string from '@adonisjs/core/helpers/string' import drive from '@adonisjs/drive/services/main' import type { HttpContext } from '@adonisjs/core/http' export default class UploadsController { /** * Generate a signed URL for direct upload */ async presign({ request, auth }: HttpContext) { const user = auth.getUserOrFail() const { filename, contentType } = request.only(['filename', 'contentType']) /** * Generate a unique key for the file */ const key = `uploads/${user.id}/${string.uuid()}-${filename}` const disk = drive.use('s3') /** * Generate a signed URL that allows the browser * to upload directly to S3 */ const signedUrl = await disk.getSignedUploadUrl(key, { expiresIn: '15 mins', contentType, }) return { key, url: signedUrl, } } /** * Handle upload completion notification */ async complete({ request, auth }: HttpContext) { const user = auth.getUserOrFail() const { key } = request.only(['key']) /** * Verify the file exists */ const disk = drive.use('s3') const exists = await disk.exists(key) if (!exists) { return { error: 'File not found' } } /** * Save the file reference to your database */ await user.related('files').create({ key }) return { success: true } } } ``` ::: :::step{title="Implement client-side upload"} On the frontend, request a signed URL and then upload the file directly to the cloud provider. ```ts title="resources/js/upload.ts" async function uploadFile(file: File) { /** * Step 1: Request a signed upload URL from your server */ const presignResponse = await fetch('/uploads/presign', { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ filename: file.name, contentType: file.type, }), }) const { key, url } = await presignResponse.json() /** * Step 2: Upload the file directly to cloud storage * using the signed URL */ const uploadResponse = await fetch(url, { method: 'PUT', headers: { 'Content-Type': file.type, }, body: file, }) if (!uploadResponse.ok) { throw new Error('Upload failed') } /** * Step 3: Notify your server that the upload is complete */ await fetch('/uploads/complete', { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ key }), }) return key } ``` ::: :::: ## Testing Drive provides a fakes API for testing file uploads without interacting with real storage. When you fake a disk, all operations are redirected to a temporary local directory instead of the configured storage service. ```ts title="tests/functional/profile/update_avatar.spec.ts" import { test } from '@japa/runner' import { fileGenerator } from '@poppinss/file-generator' import drive from '@adonisjs/drive/services/main' import { UserFactory } from '#database/factories/user_factory' test.group('Profile | update avatar', () => { test('user can upload an avatar', async ({ client }) => { /** * Fake the default disk. All file operations will now * use a temporary local directory instead of the * configured storage service. The `using` keyword * automatically restores the real disk when the test ends. */ // [!code highlight] using fakeDisk = drive.fake() const user = await UserFactory.create() /** * Generate a buffer with valid PNG magic bytes. * AdonisJS uses magic byte detection to determine file types, * so plain Buffer.from('fake-image') will be rejected. */ const pngFile = await fileGenerator.generatePng(1) await client .post('/profile/avatar') .file('avatar', pngFile.contents, { filename: pngFile.name, contentType: pngFile.mime, }) .loginAs(user) .assertStatus(200) /** * Assert the file was stored */ fakeDisk.assertExists(`${user.id}.png`) }) test('rejects invalid file types', async ({ client }) => { // [!code highlight] using fakeDisk = drive.fake() const user = await UserFactory.create() const pdfFile = await fileGenerator.generatePdf(1) await client .post('/profile/avatar') .file('avatar', pdfFile.contents, { filename: pdfFile.name, contentType: pdfFile.mime, }) .loginAs(user) .assertStatus(422) /** * Assert no file was stored */ fakeDisk.assertMissing(`${user.id}.pdf`) }) }) ``` :::note AdonisJS uses [magic byte detection](https://en.wikipedia.org/wiki/Magic_number_(programming)) to determine file types, not the filename or content type you pass. A plain `Buffer.from('fake-image')` has no valid magic bytes, so VineJS file validation will reject it with `"Invalid file extension undefined"`. Use the `@poppinss/file-generator` package (included in AdonisJS projects) to generate buffers with correct magic bytes for your tests. ::: You can also fake a specific disk: ```ts title="tests/functional/uploads.spec.ts" // Fake a specific disk using fakeDisk = drive.fake('s3') // Fake multiple disks using _s3 = drive.fake('s3') using _gcs = drive.fake('gcs') ``` You can also call `drive.restore()` manually if you need more control over when the real disk is restored. ## Troubleshooting ### Files are corrupted after upload Some cloud storage providers have issues with streaming uploads. If your files are corrupted after upload, try using the `moveAs: 'buffer'` option to read the file into memory before uploading: ```ts title="app/controllers/uploads_controller.ts" await file.moveToDisk(key, 's3', { moveAs: 'buffer', }) ``` This reads the entire file into memory before sending it to the storage provider, which can resolve compatibility issues with certain providers. ### Understanding file visibility Drive supports two visibility levels: - **public**: Files are accessible via URL by anyone. Use `getUrl` to generate URLs. - **private**: Files are not publicly accessible. Use `getSignedUrl` to generate temporary URLs with an expiration time. The visibility is set at the disk level in your configuration. All files uploaded to that disk inherit the visibility setting unless overridden. ```ts title="config/drive.ts" { services: { // All files on this disk are public publicFiles: services.s3({ // ...credentials visibility: 'public', }), // All files on this disk are private privateFiles: services.s3({ // ...credentials visibility: 'private', }), } } ``` ## See also - [File uploads guide](../basics/file_uploads.md) for handling multipart HTTP requests - [FlyDrive documentation](https://flydrive.dev/docs/introduction) for the complete Disk API reference --- # Event Emitter This guide covers the event emitter in AdonisJS applications. You will learn how to: - Define and emit type-safe events - Register listeners using callbacks or classes - Handle errors and fake events during tests ## Overview The event emitter enables event-driven architecture in AdonisJS applications. When you emit an event, all registered listeners execute asynchronously without blocking the code that triggered the event. This pattern is useful for decoupling side effects from your main application logic. A common example is user registration: after creating a user account, you might need to send a verification email, provision resources with a payment provider, and log the signup for analytics. Rather than executing all these tasks sequentially in your controller, you can emit a single `user:registered` event and let separate listeners handle each concern independently. AdonisJS provides two approaches for defining events. String-based events use TypeScript module augmentation for type-safety, while class-based events encapsulate the event identifier and data in a single class. :::note If you're looking for a list of events emitted by AdonisJS and its official packages, see the [events reference guide](../../reference/events.md). ::: ## Defining events and event data An event consists of two parts: an identifier and associated data. The identifier is typically a string like `user:registered`, and the data is whatever payload you want to pass to listeners (for example, an instance of the `User` model). Class-based events encapsulate both the identifier and the data within a single class. The class itself serves as the identifier, and instances of the class hold the event data. This approach provides built-in type-safety without additional configuration. ## String-based events String-based events use a string identifier like `user:registered` or `order:shipped`. To make these events type-safe, you define the event names and their payload types using TypeScript module augmentation. ::::steps :::step{title="Define event types"} Create a `types/events.ts` file and augment the `EventsList` interface to declare your events and their payload types. ```ts title="types/events.ts" import User from '#models/user' declare module '@adonisjs/core/types' { interface EventsList { 'user:registered': User } } ``` The `EventsList` interface maps event names to their payload types. In this example, the `user:registered` event carries a `User` model instance as its payload. TypeScript will enforce this contract when you emit events or register listeners. ::: :::step{title="Listen for the event"} Create a preload file to register your event listeners. Run the following command to generate the file. ```sh node ace make:preload events ``` This creates `start/events.ts`, which is loaded automatically when your application boots. Register listeners using the `emitter.on` method. ```ts title="start/events.ts" import emitter from '@adonisjs/core/services/emitter' emitter.on('user:registered', function (user) { console.log(user.email) }) ``` The listener callback receives the event payload as its argument. Because you defined the payload type in `EventsList`, TypeScript knows that `user` is an instance of the `User` model. ::: :::step{title="Emit the event"} Emit events from anywhere in your application using `emitter.emit`. The first argument is the event name, and the second is the payload. ```ts title="app/controllers/users_controller.ts" import emitter from '@adonisjs/core/services/emitter' import User from '#models/user' export default class UsersController { async store({ request }: HttpContext) { const data = request.only(['email', 'password']) const user = await User.create(data) // [!code highlight] emitter.emit('user:registered', user) return user } } ``` The `emitter.emit` method is type-safe. TypeScript will error if you pass an incorrect payload type or use an event name that isn't defined in `EventsList`. ::: :::: ## Class-based events Class-based events provide type-safety without module augmentation. The event class acts as both the identifier and a container for the event data. ::::steps :::step{title="Create an event class"} Generate an event class using the `make:event` command. ```sh node ace make:event UserRegistered ``` This creates an event class that extends `BaseEvent`. Accept event data through the constructor and expose it as instance properties. ```ts title="app/events/user_registered.ts" import { BaseEvent } from '@adonisjs/core/events' import User from '#models/user' export default class UserRegistered extends BaseEvent { constructor(public user: User) { super() } } ``` The event class has no behavior. It's purely a data container where the constructor parameters define what data the event carries. ::: :::step{title="Listen for the event"} Import the event class from the `#generated/events` [barrel file](../concepts/barrel_files.md) and use it as the first argument to `emitter.on`. ```ts title="start/events.ts" import emitter from '@adonisjs/core/services/emitter' import { events } from '#generated/events' emitter.on(events.UserRegistered, function (event) { console.log(event.user.email) }) ``` The listener receives an instance of the event class. Access the event data through the instance properties you defined in the constructor. ::: :::step{title="Dispatch the event"} Class-based events are dispatched using the static `dispatch` method instead of `emitter.emit`. ```ts title="app/controllers/users_controller.ts" import User from '#models/user' import { events } from '#generated/events' export default class UsersController { async store({ request }: HttpContext) { const data = request.only(['email', 'password']) const user = await User.create(data) // [!code highlight] events.UserRegistered.dispatch(user) return user } } ``` The `dispatch` method accepts the same arguments as the event class constructor. There's no need to define types in `EventsList` since the class itself provides complete type information. ::: :::: ## Listeners Listeners can be defined as inline callbacks or as dedicated listener classes. Inline callbacks work well for simple logic, while listener classes are better for complex operations that benefit from dependency injection and testability. ### Inline callbacks Pass a function directly to `emitter.on` for simple listeners. ```ts title="start/events.ts" import emitter from '@adonisjs/core/services/emitter' emitter.on('user:registered', function (user) { console.log(`New user: ${user.email}`) }) ``` The same approach works with class-based events. ```ts title="start/events.ts" import emitter from '@adonisjs/core/services/emitter' import { events } from '#generated/events' emitter.on(events.UserRegistered, function (event) { console.log(`New user: ${event.user.email}`) }) ``` ### Listener classes Create a listener class using the `make:listener` command. ```sh node ace make:listener SendVerificationEmail ``` This generates a class with a `handle` method that executes when the event fires. ```ts title="app/listeners/send_verification_email.ts" export default class SendVerificationEmail { async handle() { // Send email } } ``` Update the `handle` method to accept the event payload. For class-based events, type the parameter as the event class. ```ts title="app/listeners/send_verification_email.ts" import { events } from '#generated/events' export default class SendVerificationEmail { async #sendEmail(to: string) { } async handle(event: events.UserRegistered) { await this.#sendEmail(event.user.email) } } ``` Register the listener by importing it from the `#generated/listeners` [barrel file](../concepts/barrel_files.md). ```ts title="start/events.ts" import emitter from '@adonisjs/core/services/emitter' import { events } from '#generated/events' import { listeners } from '#generated/listeners' emitter.on(events.UserRegistered, listeners.SendVerificationEmail) ``` ### Dependency injection in listeners Listener classes are instantiated through the IoC container, so you can inject dependencies via the constructor using the `@inject` decorator. See also [dependency injection guide](../concepts/dependency_injection.md). ```ts title="app/listeners/send_verification_email.ts" import { inject } from '@adonisjs/core' import { events } from '#generated/events' import TokensService from '#services/tokens_service' @inject() export default class SendVerificationEmail { constructor(protected tokensService: TokensService) {} async handle(event: events.UserRegistered) { const token = this.tokensService.generate(event.user.email) } } ``` ## Listening methods The emitter provides several methods for registering listeners, each suited to different use cases. ### Persistent listeners with `on` The `on` method registers a listener that fires every time the event is emitted throughout the application lifecycle. ```ts emitter.on('user:registered', function (user) { // Runs every time the event fires }) ``` ### One-time listeners with `once` The `once` method registers a listener that fires only once, then automatically unsubscribes. ```ts emitter.once('user:registered', function (user) { // Runs only the first time the event fires }) ``` ### Multiple listeners with `listen` The `listen` method registers multiple listeners for a single event in one call. ```ts title="start/events.ts" import emitter from '@adonisjs/core/services/emitter' import { events } from '#generated/events' import { listeners } from '#generated/listeners' emitter.listen(events.UserRegistered, [ listeners.SendVerificationEmail, listeners.RegisterWithPaymentProvider, listeners.ProvisionAccount, ]) ``` All listeners execute in parallel when the event fires. ### Wildcard listeners with `onAny` The `onAny` method registers a listener that fires for every event emitted in the application. ```ts emitter.onAny((name, event) => { console.log(`Event fired: ${name}`) console.log(event) }) ``` This is useful for logging, debugging, or implementing cross-cutting concerns that apply to all events. ## Unsubscribing from events You can remove listeners when they're no longer needed. ### Using the unsubscribe function The `on` and `once` methods return an unsubscribe function. Call it to remove the listener. ```ts import emitter from '@adonisjs/core/services/emitter' const unsubscribe = emitter.on('user:registered', () => { }) // [!code highlight] unsubscribe() ``` ### Using the `off` method The `off` method removes a specific listener. You need a reference to the exact function or class that was registered. ```ts import emitter from '@adonisjs/core/services/emitter' function sendEmail() { } emitter.on('user:registered', sendEmail) // [!code highlight] emitter.off('user:registered', sendEmail) ``` This works with listener classes too. ```ts import emitter from '@adonisjs/core/services/emitter' import { events } from '#generated/events' import { listeners } from '#generated/listeners' emitter.on(events.UserRegistered, listeners.SendVerificationEmail) emitter.off(events.UserRegistered, listeners.SendVerificationEmail) ``` ### Clearing listeners Remove all listeners for a specific event with `clearListeners`. ```ts emitter.clearListeners('user:registered') emitter.clearListeners(events.UserRegistered) ``` Remove all listeners for all events with `clearAllListeners`. ```ts emitter.clearAllListeners() ``` ## Error handling When a listener throws an error, it doesn't affect other listeners since they run in parallel. However, unhandled errors will trigger Node.js [unhandledRejection](https://nodejs.org/api/process.html#event-unhandledrejection) events, which can crash your application or cause unexpected behavior. Define a global error handler to catch and process errors from all listeners. ```ts title="start/events.ts" import emitter from '@adonisjs/core/services/emitter' emitter.onError((event, error, eventData) => { console.error(`Error in listener for ${event}:`, error) // Report to error tracking service }) ``` The error handler receives three arguments: the event name (or class), the error that was thrown, and the event data that was passed to the listener. ## Faking events during tests When testing code that emits events, you often want to verify the event was emitted without actually running the listeners. For example, when testing user registration, you might want to confirm the `user:registered` event fires without sending real emails. The `emitter.fake` method prevents listeners from running and returns an `EventBuffer` that captures emitted events for assertions. ```ts import emitter from '@adonisjs/core/services/emitter' import { events } from '#generated/events' test.group('User signup', () => { test('create a user account', async ({ client }) => { // [!code highlight:2] using fakeEmitter = emitter.fake() await client .post('signup') .form({ email: 'foo@bar.com', password: 'secret', }) // [!code highlight] fakeEmitter.assertEmitted(events.UserRegistered) }) }) ``` The `using` keyword automatically restores the emitter when the variable goes out of scope (at the end of the test function). You can also call `emitter.restore()` manually if you need more control over when restoration happens. ### Faking specific events Pass event names or classes to `fake` to only fake specific events. Other events will continue to trigger their listeners normally. ```ts emitter.fake('user:registered') emitter.fake([events.UserRegistered, events.OrderUpdated]) ``` ### Assertions The `EventBuffer` returned by `fake` provides several assertion methods. ```ts const fakeEmitter = emitter.fake() // Assert an event was emitted fakeEmitter.assertEmitted('user:registered') fakeEmitter.assertEmitted(events.UserRegistered) // Assert an event was not emitted fakeEmitter.assertNotEmitted(events.OrderUpdated) // Assert an event was emitted a specific number of times fakeEmitter.assertEmittedCount(events.OrderUpdated, 1) // Assert no events were emitted at all fakeEmitter.assertNoneEmitted() ``` ### Conditional assertions Pass a callback to `assertEmitted` to assert that an event was emitted with specific data. ```ts fakeEmitter.assertEmitted(events.OrderUpdated, ({ data }) => { /** * Only consider the event as emitted if * the orderId matches */ return data.order.id === orderId }) ``` The callback receives the event data and should return `true` if the event matches your criteria. --- # Health checks This guide covers health checks in AdonisJS applications. You will learn how to: - Understand the difference between liveness and readiness probes - Configure health checks using the built-in setup command - Expose endpoints for monitoring services and orchestrators - Use built-in checks for disk space, memory, database, and Redis - Cache health check results for performance optimization - Create custom health checks for application-specific needs ## Overview Health checks allow your application to report its operational status to external systems like load balancers, container orchestrators (Kubernetes, Docker Swarm), and monitoring services. These systems periodically probe your application to determine whether it should receive traffic or be restarted. AdonisJS provides a health check system built into the core framework with several ready-made checks for common infrastructure concerns. You can also create custom checks for application-specific requirements like verifying external API connectivity or queue connections. ## Liveness vs readiness Before implementing health checks, it's important to understand the two types of probes and when each should be used. A **liveness probe** answers the question: "Is the process alive and responsive?" This is a simple check that verifies your application can respond to HTTP requests. If a liveness probe fails repeatedly, the orchestrator will restart the container. Liveness probes should be lightweight and avoid checking external dependencies, since a database outage shouldn't cause your application to enter a restart loop. A **readiness probe** answers the question: "Is the application ready to accept traffic?" This check verifies that your application and its dependencies (database connections, Redis, external services) are functioning correctly. If a readiness probe fails, the orchestrator removes the instance from the load balancer but does not restart it. This allows the application time to recover, for example, when a database connection is temporarily unavailable. | Probe | Purpose | On failure | Should check dependencies | |-------|---------|------------|---------------------------| | Liveness | Is the process alive? | Restart container | No | | Readiness | Can it handle requests? | Remove from load balancer | Yes | ## Configuring health checks Run the following command to set up health checks in your application. This creates the configuration file and a controller with both liveness and readiness endpoints. ```sh node ace configure health_checks ``` The command creates two files. The first is the health checks configuration where you register which checks to run. ```ts title="start/health.ts" import { HealthChecks, DiskSpaceCheck, MemoryHeapCheck } from '@adonisjs/core/health' export const healthChecks = new HealthChecks().register([ new DiskSpaceCheck(), new MemoryHeapCheck(), ]) ``` The second is a controller that exposes both probe endpoints. ```ts title="app/controllers/health_checks_controller.ts" import { healthChecks } from '#start/health' import type { HttpContext } from '@adonisjs/core/http' export default class HealthChecksController { /** * Liveness probe: Returns 200 if the process is running. * Does not check dependencies. */ async live({ response }: HttpContext) { return response.ok() } /** * Readiness probe: Runs all registered health checks * and returns the detailed report. */ async ready({ response }: HttpContext) { const report = await healthChecks.run() if (report.isHealthy) { return response.ok(report) } return response.serviceUnavailable(report) } } ``` The liveness method simply returns a 200 status code, proving the process is alive and can handle HTTP requests. The readiness method runs all registered health checks and returns 200 when healthy or 503 (Service Unavailable) when any check fails. ## Exposing endpoints Register the health check routes in your routes file. Using separate paths for each probe allows orchestrators to configure them independently. ```ts title="start/routes.ts" import router from '@adonisjs/core/services/router' import { controllers } from '#generated/controllers' router.get('/health/live', [controllers.HealthChecks, 'live']) router.get('/health/ready', [controllers.HealthChecks, 'ready']) ``` With these routes in place, your monitoring system can probe `/health/live` for liveness and `/health/ready` for readiness checks. ### Understanding the readiness report The readiness endpoint returns a detailed JSON report containing the results of all registered checks. ```json { "isHealthy": true, "status": "warning", "finishedAt": "2024-06-20T07:09:35.275Z", "debugInfo": { "pid": 16250, "ppid": 16051, "platform": "darwin", "uptime": 16.271809083, "version": "v21.7.3" }, "checks": [ { "name": "Disk space check", "isCached": false, "message": "Disk usage is 76%, which is above the threshold of 75%", "status": "warning", "finishedAt": "2024-06-20T07:09:35.275Z", "meta": { "sizeInPercentage": { "used": 76, "failureThreshold": 80, "warningThreshold": 75 } } }, { "name": "Memory heap check", "isCached": false, "message": "Heap usage is under defined thresholds", "status": "ok", "finishedAt": "2024-06-20T07:09:35.265Z", "meta": { "memoryInBytes": { "used": 41821592, "failureThreshold": 314572800, "warningThreshold": 262144000 } } } ] } ``` The report contains the following properties: ::::options :::option{name="isHealthy"} Boolean indicating whether all checks passed. Set to `false` if one or more checks fail. ::: :::option{name="status"} Overall status: `ok` (all passed), `warning` (warnings present), or `error` (failures present). ::: :::option{name="finishedAt"} Timestamp when the checks completed. ::: :::option{name="debugInfo"} Process information including PID, platform, uptime in seconds, and Node.js version. ::: :::option{name="checks"} Array containing the detailed result of each registered check. Each check in the `checks` array includes its name, status, message, whether the result was cached, and any metadata specific to that check type. ::: :::: ### Protecting the readiness endpoint The readiness report contains detailed information about your infrastructure that you may not want exposed publicly. You can protect the endpoint using a secret header that your monitoring system includes with each request. Kubernetes and most monitoring tools support custom HTTP headers on probe requests, so you can configure them to include your secret. ```ts title="start/routes.ts" import router from '@adonisjs/core/services/router' const HealthChecksController = () => import('#controllers/health_checks_controller') router.get('/health/live', [HealthChecksController, 'live']) router .get('/health/ready', [HealthChecksController, 'ready']) // [!code ++:6] .use(({ request, response }, next) => { if (request.header('x-monitoring-secret') === 'some_secret_value') { return next() } return response.unauthorized({ message: 'Unauthorized access' }) }) ``` In Kubernetes, configure the probe to include the header. ```yaml readinessProbe: httpGet: path: /health/ready port: 3333 httpHeaders: - name: x-monitoring-secret value: some_secret_value ``` ## Available health checks AdonisJS provides several built-in health checks that you can register in your `start/health.ts` file. ### DiskSpaceCheck Monitors available disk space and reports warnings or errors when usage exceeds configured thresholds. ```ts title="start/health.ts" import { HealthChecks, DiskSpaceCheck } from '@adonisjs/core/health' export const healthChecks = new HealthChecks().register([ new DiskSpaceCheck() ]) ``` The default warning threshold is 75% and the failure threshold is 80%. You can customize these values. ```ts title="start/health.ts" export const healthChecks = new HealthChecks().register([ new DiskSpaceCheck() .warnWhenExceeds(80) .failWhenExceeds(90), ]) ``` ### MemoryHeapCheck Monitors the heap size reported by `process.memoryUsage()` and alerts when memory consumption exceeds thresholds. ```ts title="start/health.ts" import { HealthChecks, MemoryHeapCheck } from '@adonisjs/core/health' export const healthChecks = new HealthChecks().register([ new MemoryHeapCheck() ]) ``` The default warning threshold is 250MB and the failure threshold is 300MB. You can customize these values using human-readable strings. ```ts title="start/health.ts" export const healthChecks = new HealthChecks().register([ new MemoryHeapCheck() .warnWhenExceeds('300 mb') .failWhenExceeds('700 mb'), ]) ``` ### MemoryRSSCheck Monitors the Resident Set Size (total memory allocated for the process) reported by `process.memoryUsage()`. ```ts title="start/health.ts" import { HealthChecks, MemoryRSSCheck } from '@adonisjs/core/health' export const healthChecks = new HealthChecks().register([ new MemoryRSSCheck() ]) ``` The default warning threshold is 320MB and the failure threshold is 350MB. ```ts title="start/health.ts" export const healthChecks = new HealthChecks().register([ new MemoryRSSCheck() .warnWhenExceeds('600 mb') .failWhenExceeds('800 mb'), ]) ``` ### DbCheck Provided by the `@adonisjs/lucid` package, this check verifies connectivity to your SQL database. ```ts title="start/health.ts" import db from '@adonisjs/lucid/services/db' import { DbCheck } from '@adonisjs/lucid/database' import { HealthChecks, DiskSpaceCheck, MemoryHeapCheck } from '@adonisjs/core/health' export const healthChecks = new HealthChecks().register([ new DiskSpaceCheck(), new MemoryHeapCheck(), // [!code ++:1] new DbCheck(db.connection()), ]) ``` A successful check returns a report like this. ```json { "name": "Database health check (postgres)", "isCached": false, "message": "Successfully connected to the database server", "status": "ok", "finishedAt": "2024-06-20T07:18:23.830Z", "meta": { "connection": { "name": "postgres", "dialect": "postgres" } } } ``` To monitor multiple database connections, register the check once for each connection. ```ts title="start/health.ts" export const healthChecks = new HealthChecks().register([ new DiskSpaceCheck(), new MemoryHeapCheck(), // [!code ++:3] new DbCheck(db.connection()), new DbCheck(db.connection('mysql')), new DbCheck(db.connection('pg')), ]) ``` ### DbConnectionCountCheck Monitors active database connections and alerts when the count exceeds thresholds. This check is supported for PostgreSQL and MySQL databases only. ```ts title="start/health.ts" import db from '@adonisjs/lucid/services/db' import { DbCheck, DbConnectionCountCheck } from '@adonisjs/lucid/database' import { HealthChecks, DiskSpaceCheck, MemoryHeapCheck } from '@adonisjs/core/health' export const healthChecks = new HealthChecks().register([ new DiskSpaceCheck(), new MemoryHeapCheck(), new DbCheck(db.connection()), // [!code ++:1] new DbConnectionCountCheck(db.connection()), ]) ``` The default warning threshold is 10 connections and the failure threshold is 15 connections. ```ts title="start/health.ts" new DbConnectionCountCheck(db.connection()) .warnWhenExceeds(4) .failWhenExceeds(10) ``` ### RedisCheck Provided by the `@adonisjs/redis` package, this check verifies connectivity to your Redis server, including cluster configurations. ```ts title="start/health.ts" import redis from '@adonisjs/redis/services/main' import { RedisCheck } from '@adonisjs/redis' import { HealthChecks, DiskSpaceCheck, MemoryHeapCheck } from '@adonisjs/core/health' export const healthChecks = new HealthChecks().register([ new DiskSpaceCheck(), new MemoryHeapCheck(), // [!code ++:1] new RedisCheck(redis.connection()), ]) ``` To monitor multiple Redis connections, register the check once for each connection. ```ts title="start/health.ts" export const healthChecks = new HealthChecks().register([ new DiskSpaceCheck(), new MemoryHeapCheck(), // [!code ++:3] new RedisCheck(redis.connection()), new RedisCheck(redis.connection('cache')), new RedisCheck(redis.connection('locks')), ]) ``` ### RedisMemoryUsageCheck Monitors memory consumption on the Redis server and alerts when usage exceeds thresholds. ```ts title="start/health.ts" import redis from '@adonisjs/redis/services/main' import { RedisCheck, RedisMemoryUsageCheck } from '@adonisjs/redis' import { HealthChecks, DiskSpaceCheck, MemoryHeapCheck } from '@adonisjs/core/health' export const healthChecks = new HealthChecks().register([ new DiskSpaceCheck(), new MemoryHeapCheck(), new RedisCheck(redis.connection()), // [!code ++:1] new RedisMemoryUsageCheck(redis.connection()), ]) ``` The default warning threshold is 100MB and the failure threshold is 120MB. ```ts title="start/health.ts" new RedisMemoryUsageCheck(redis.connection()) .warnWhenExceeds('200 mb') .failWhenExceeds('240 mb') ``` ## Caching results Health checks run every time the readiness endpoint is called. For checks that don't need to run on every request (like disk space, which changes slowly), you can cache results for a specified duration. ```ts title="start/health.ts" import { HealthChecks, MemoryRSSCheck, DiskSpaceCheck, MemoryHeapCheck, } from '@adonisjs/core/health' export const healthChecks = new HealthChecks().register([ // [!code ++:1] new DiskSpaceCheck().cacheFor('1 hour'), new MemoryHeapCheck(), new MemoryRSSCheck(), ]) ``` In this example, the disk space check runs at most once per hour, returning the cached result for subsequent requests. Memory checks run on every request since memory usage can change rapidly. When a result is served from cache, the `isCached` property in the check's report will be `true`. ## Creating custom health checks You can create custom health checks to verify application-specific requirements like external API connectivity, message queue connections, or business-critical service availability. A custom health check is a class that extends `BaseCheck` and implements the `run` method. You can place custom checks anywhere in your project, though `app/health_checks/` is a sensible location. ```ts title="app/health_checks/payment_gateway_check.ts" import { Result, BaseCheck } from '@adonisjs/core/health' import type { HealthCheckResult } from '@adonisjs/core/types/health' export class PaymentGatewayCheck extends BaseCheck { async run(): Promise { /** * Attempt to reach the payment gateway's health endpoint. * In a real implementation, you would make an HTTP request * to verify the service is reachable. */ const isReachable = await this.checkGatewayConnectivity() if (!isReachable) { return Result.failed('Payment gateway is unreachable') } const latency = await this.measureLatency() if (latency > 2000) { return Result.warning('Payment gateway latency is high').mergeMetaData({ latencyMs: latency, }) } return Result.ok('Payment gateway is healthy').mergeMetaData({ latencyMs: latency, }) } protected async checkGatewayConnectivity(): Promise { // Implementation here return true } protected async measureLatency(): Promise { // Implementation here return 150 } } ``` The `Result` class provides three factory methods for creating health check results: | Method | Usage | |--------|-------| | `Result.ok(message)` | Check passed successfully | | `Result.warning(message)` | Check passed but with concerns | | `Result.failed(message, error?)` | Check failed, optionally include the error | Use the `mergeMetaData` method to include additional information in the check's report, such as latency measurements, connection counts, or version numbers. ### Registering custom health checks Import your custom check in `start/health.ts` and register it with the others. ```ts title="start/health.ts" import { HealthChecks, DiskSpaceCheck, MemoryHeapCheck } from '@adonisjs/core/health' // [!code ++:1] import { PaymentGatewayCheck } from '#health_checks/payment_gateway_check' export const healthChecks = new HealthChecks().register([ new DiskSpaceCheck().cacheFor('1 hour'), new MemoryHeapCheck(), // [!code ++:1] new PaymentGatewayCheck(), ]) ``` Your custom check will now run alongside the built-in checks whenever the readiness endpoint is called. --- # Internationalization and Localization This guide covers internationalization (i18n) and localization in AdonisJS applications. You will learn how to: - Configure the i18n package and set up supported locales - Store and organize translation files for multiple languages - Resolve and format translations in controllers and Edge templates - Translate validation error messages automatically - Use ICU message format for dynamic content (plurals, dates, numbers, gender) - Format values like currencies, dates, and relative times - Create custom translation loaders and formatters ## Overview When building applications for a global audience, you need two capabilities: **localization** (translating text into multiple languages) and **internationalization** (formatting values like dates, numbers, and currencies according to regional conventions). The `@adonisjs/i18n` package provides both. Localization involves writing translations for each language your application supports and referencing them in Edge templates, validation messages, or directly via the i18n API. Instead of hardcoding strings like "Welcome back!" throughout your codebase, you store translations in dedicated files and look them up by key. This makes it straightforward to add new languages without modifying your application code. Internationalization handles the formatting side. The same date might display as "January 10, 2026" in the US but "10 janvier 2026" in France. The i18n package uses the browser-standard Intl API under the hood, giving you locale-aware formatting for numbers, currencies, dates, times, and more. The package integrates with AdonisJS through middleware that detects the user's preferred language from the `Accept-Language` header, creates a locale-specific i18n instance, and makes it available throughout the request lifecycle via HTTP Context. ## Installation Install and configure the package using the following command: ```sh node ace add @adonisjs/i18n ``` :::disclosure{title="See steps performed by the add command"} 1. Installs the `@adonisjs/i18n` package using the detected package manager. 2. Registers the following service provider inside the `adonisrc.ts` file. ```ts title="adonisrc.ts" { providers: [ // ...other providers () => import('@adonisjs/i18n/i18n_provider') ] } ``` 3. Creates the `config/i18n.ts` file. 4. Creates `detect_user_locale_middleware` inside the `middleware` directory. 5. Registers the following middleware inside the `start/kernel.ts` file. ```ts title="start/kernel.ts" router.use([ () => import('#middleware/detect_user_locale_middleware') ]) ``` ::: ## Configuration The configuration for the i18n package is stored within the `config/i18n.ts` file. ```ts title="config/i18n.ts" import app from '@adonisjs/core/services/app' import { defineConfig, formatters, loaders } from '@adonisjs/i18n' const i18nConfig = defineConfig({ defaultLocale: 'en', formatter: formatters.icu(), loaders: [ loaders.fs({ location: app.languageFilesPath() }) ], }) export default i18nConfig ``` See also: [Config stub](https://github.com/adonisjs/i18n/blob/3.x/stubs/config/i18n.stub) ### Configuration options | Option | Description | |--------|-------------| | `defaultLocale` | The fallback locale when your application does not support the user's language. Translations and value formatting fall back to this locale. | | `formatter` | The message format for storing translations. AdonisJS uses the [ICU message format](https://format-message.github.io/icu-message-format-for-translators/index.html) by default, a widely accepted standard supported by translation services like Crowdin and Lokalise. You can also [create custom formatters](#creating-a-custom-translation-formatter). | | `fallbackLocales` | A key-value pair defining fallback relationships between locales. For example, you might show Spanish content to users who speak Catalan. | | `supportedLocales` | An array of locales your application supports. If omitted, this is inferred from your translation files. | | `loaders` | A collection of loaders for loading translations. The default filesystem loader reads from `resources/lang`. You can [create custom loaders](#creating-a-custom-translation-loader) to load translations from a database or remote service. | ### Configuring fallback locales When a translation is missing for a specific locale, the i18n package can fall back to a related language before using the default locale. This is useful for regional variants. ```ts title="config/i18n.ts" export default defineConfig({ formatter: formatters.icu(), defaultLocale: 'en', // [!code ++:4] fallbackLocales: { 'de-CH': 'de', // Swiss German falls back to German 'fr-CH': 'fr', // Swiss French falls back to French ca: 'es' // Catalan falls back to Spanish } }) ``` ### Configuring supported locales By default, the package infers supported locales from your translation files. If you have translation directories for `en`, `fr`, and `es`, those become your supported locales automatically. To explicitly define supported locales, use the `supportedLocales` option. ```ts title="config/i18n.ts" export default defineConfig({ formatter: formatters.icu(), defaultLocale: 'en', // [!code ++:1] supportedLocales: ['en', 'fr', 'it'] }) ``` ## Storing translations Translations are stored in the `resources/lang` directory. Create a subdirectory for each language using the [IETF language tag](https://en.wikipedia.org/wiki/IETF_language_tag) format (like `en`, `fr`, `de`). ```sh resources ├── lang │ ├── en │ └── fr ``` For regional variants, create subdirectories with the region code. AdonisJS automatically falls back from regional to base translations when a key is missing. ```sh resources ├── lang │ ├── en # English (base) │ ├── en-us # English (United States) │ └── en-gb # English (United Kingdom) ``` See also: [ISO language codes](https://www.andiamo.co.uk/resources/iso-language-codes/) ### Translation file format Store translations in `.json` or `.yaml` files. You can create nested directories for better organization. ```sh resources ├── lang │ ├── en │ │ └── messages.json │ └── fr │ └── messages.json ``` Translations use the [ICU message syntax](https://format-message.github.io/icu-message-format-for-translators/index.html), which supports interpolation, pluralization, and formatting. ```json title="resources/lang/en/messages.json" { "greeting": "Hello world" } ``` ```json title="resources/lang/fr/messages.json" { "greeting": "Bonjour le monde" } ``` ## Resolving translations To look up and format translations, create a locale-specific instance of the I18n class using the `i18nManager.locale` method. ```ts title="app/services/example_service.ts" import i18nManager from '@adonisjs/i18n/services/main' /** * Create I18n instances for specific locales */ const en = i18nManager.locale('en') const fr = i18nManager.locale('fr') ``` Use the `.t` method to format a translation by its key. The key follows the pattern `filename.path.to.key`. ```ts title="app/services/example_service.ts" import i18nManager from '@adonisjs/i18n/services/main' const i18n = i18nManager.locale('en') i18n.t('messages.greeting') // "Hello world" ``` ```ts title="app/services/example_service.ts" import i18nManager from '@adonisjs/i18n/services/main' const i18n = i18nManager.locale('fr') i18n.t('messages.greeting') // "Bonjour le monde" ``` ### Understanding fallback behavior Each I18n instance has a pre-configured fallback language based on your `fallbackLocales` configuration. When a translation is missing for the main language, the fallback is used. ```ts title="config/i18n.ts" export default defineConfig({ defaultLocale: 'en', fallbackLocales: { 'de-CH': 'de', 'fr-CH': 'fr' } }) ``` ```ts title="app/services/example_service.ts" import i18nManager from '@adonisjs/i18n/services/main' const swissGerman = i18nManager.locale('de-CH') swissGerman.fallbackLocale // "de" (from fallbackLocales) const swissFrench = i18nManager.locale('fr-CH') swissFrench.fallbackLocale // "fr" (from fallbackLocales) const english = i18nManager.locale('en') english.fallbackLocale // "en" (uses defaultLocale) ``` ### Handling missing translations When a translation is missing in both the main and fallback locales, the `.t` method returns an error string. ```ts title="app/services/example_service.ts" import i18nManager from '@adonisjs/i18n/services/main' const i18n = i18nManager.locale('en') i18n.t('messages.hero_title') // "translation missing: en, messages.hero_title" ``` To replace this with a custom fallback value, pass it as the second parameter. ```ts title="app/services/example_service.ts" import i18nManager from '@adonisjs/i18n/services/main' const i18n = i18nManager.locale('en') i18n.t('messages.hero_title', '') // "" (empty string instead of error message) ``` For a global fallback strategy, define a `fallback` function in your config. This function receives the translation path and locale code, and must return a string. ```ts title="config/i18n.ts" import { defineConfig } from '@adonisjs/i18n' export default defineConfig({ defaultLocale: 'en', formatter: formatters.icu(), // [!code ++:3] fallback: (identifier, locale) => { return '' // Return empty string for all missing translations }, }) ``` ## Detecting user locale during HTTP requests The `detect_user_locale_middleware` created during installation handles locale detection automatically. It reads the [`Accept-Language` header](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Accept-Language) from incoming requests, creates an I18n instance for the detected locale, and shares it via [HTTP Context](../basics/http_context.md). With this middleware active, you can access translations in controllers and Edge templates. ```ts title="app/controllers/posts_controller.ts" import type { HttpContext } from '@adonisjs/core/http' export default class PostsController { async store({ i18n, session, response }: HttpContext) { // Create post... session.flash('success', { message: i18n.t('post.created') }) return response.redirect().back() } } ``` In Edge templates, use the `t` helper function. ```edge title="resources/views/welcome.edge"

{{ t('messages.heroTitle') }}

``` ### Customizing locale detection The middleware is part of your application codebase, so you can modify the `getRequestLocale` method to use custom detection logic. For example, you might read the locale from a user's profile, a cookie, or a URL parameter instead of the `Accept-Language` header. ## Translating validation messages The `detect_user_locale_middleware` hooks into the [Request validator](../basics/validation.md#the-requestvalidateusing-method) to provide validation messages from your translation files. ```ts title="app/middleware/detect_user_locale_middleware.ts" export default class DetectUserLocaleMiddleware { static { RequestValidator.messagesProvider = (ctx) => { return ctx.i18n.createMessagesProvider() } } } ``` Store validation translations in a `validator.json` file under the `shared` key. Define messages for validation rules or specific `field + rule` combinations. ```json title="resources/lang/en/validator.json" { "shared": { "fields": { "first_name": "first name" }, "messages": { "required": "Enter {field}", "username.required": "Choose a username for your account", "email": "The email must be valid" } } } ``` ```json title="resources/lang/fr/validator.json" { "shared": { "fields": { "first_name": "Prénom" }, "messages": { "required": "Remplisser le champ {field}", "username.required": "Choissisez un nom d'utilisateur pour votre compte", "email": "L'email doit être valide" } } } ``` ### Using translations with VineJS directly During HTTP requests, the middleware automatically registers a [custom messages provider](https://vinejs.dev/docs/custom_error_messages#registering-messages-provider) for validation. When using VineJS outside of HTTP requests (in Ace commands or queue jobs), register the messages provider explicitly. ```ts title="app/jobs/create_user_job.ts" import { createJobValidator } from '#validators/jobs' import i18nManager from '@adonisjs/i18n/services/main' /** * Get an i18n instance for the desired locale */ const i18n = i18nManager.locale('fr') await createJobValidator.validate(data, { /** * Register the messages provider manually */ // [!code ++:1] messagesProvider: i18n.createMessagesProvider() }) ``` ## ICU message format The ICU (International Components for Unicode) message format is the standard for storing translations that need dynamic content. It supports interpolation, number formatting, date formatting, pluralization, and gender-based selection. ### Interpolation Reference dynamic values using single curly braces. ```json title="resources/lang/en/messages.json" { "greeting": "Hello { username }" } ``` ```edge title="resources/views/welcome.edge" {{ t('messages.greeting', { username: 'Virk' }) }} {{-- Output: Hello Virk --}} ``` :::tip The ICU messages syntax [does not support nested data sets](https://github.com/formatjs/formatjs/pull/2039#issuecomment-951550150). You can only access properties from a flat object during interpolation. If you need nested data, flatten it before passing to the translation function. ::: To include HTML in translations, use three curly braces in Edge templates to prevent escaping. ```json title="resources/lang/en/messages.json" { "greeting": "

Hello { username }

" } ``` ```edge title="resources/views/welcome.edge" {{{ t('messages.greeting', { username: 'Virk' }) }}} ``` ### Number formatting Format numeric values using the `{key, number, format}` syntax. The format can include [number skeletons](https://unicode-org.github.io/icu/userguide/format_parse/numbers/skeletons.html#overview) for precise control. ```json title="resources/lang/en/messages.json" { "bagel_price": "The price of this bagel is {amount, number, ::currency/USD}" } ``` ```edge title="resources/views/menu.edge" {{ t('bagel_price', { amount: 2.49 }) }} {{-- Output: The price of this bagel is $2.49 --}} ``` More formatting examples: | Message | Output | |---------|--------| | `Length: {length, number, ::measure-unit/length-meter}` | Length: 5 m | | `Balance: {price, number, ::currency/USD compact-long}` | Balance: $1 thousand | ### Date and time formatting Format Date objects or [Luxon DateTime](https://moment.github.io/luxon/api-docs/index.html) instances using the `{key, date, format}` or `{key, time, format}` syntax. ```json title="resources/lang/en/messages.json" { "shipment_update": "Your package will arrive on {expectedDate, date, medium}" } ``` ```edge title="resources/views/order.edge" {{ t('shipment_update', { expectedDate: luxonDateTime }) }} {{-- Output: Your package will arrive on Oct 16, 2023 --}} ``` For time formatting: ```json title="resources/lang/en/messages.json" { "appointment": "You have an appointment today at {appointmentAt, time, ::h:m a}" } ``` ```txt You have an appointment today at 2:48 PM ``` #### Supported date/time patterns ICU provides many patterns, but only the following are available via the ECMA402 Intl API: | Symbol | Description | |--------|-------------| | `G` | Era designator | | `y` | Year | | `M` | Month in year | | `L` | Stand-alone month in year | | `d` | Day in month | | `E` | Day of week | | `e` | Local day of week (e..eee not supported) | | `c` | Stand-alone local day of week (c..ccc not supported) | | `a` | AM/PM marker | | `h` | Hour [1-12] | | `H` | Hour [0-23] | | `K` | Hour [0-11] | | `k` | Hour [1-24] | | `m` | Minute | | `s` | Second | | `z` | Time Zone | ### Plural rules ICU has first-class support for pluralization. The `plural` format selects different text based on a numeric value. ```yaml title="resources/lang/en/messages.yaml" cart_summary: > You have {itemsCount, plural, =0 {no items} one {1 item} other {# items} } in your cart ``` ```edge title="resources/views/cart.edge" {{ t('messages.cart_summary', { itemsCount: 1 }) }} {{-- Output: You have 1 item in your cart --}} ``` The `#` token is a placeholder for the numeric value, formatted according to locale rules. ```edge title="resources/views/cart.edge" {{ t('messages.cart_summary', { itemsCount: 1000 }) }} {{-- Output: You have 1,000 items in your cart --}} ``` #### Plural categories The `{key, plural, matches}` syntax matches values to these categories: | Category | When used | |----------|-----------| | `zero` | Languages with grammar for zero items (Arabic, Latvian) | | `one` | Languages with grammar for one item (many Western languages) | | `two` | Languages with grammar for two items (Arabic, Welsh) | | `few` | Languages with grammar for small numbers (varies by language) | | `many` | Languages with grammar for larger numbers (Arabic, Polish, Russian) | | `other` | Default category when no other matches; used for "plural" in languages like English | | `=value` | Matches a specific numeric value exactly | ### Select format The `select` format chooses output by matching a value against multiple options. This is useful for gender-specific text or other categorical choices. ```yaml title="resources/lang/en/messages.yaml" auto_reply: > {gender, select, male {He} female {She} other {They} } will respond shortly. ``` ```edge title="resources/views/contact.edge" {{ t('messages.auto_reply', { gender: 'female' }) }} {{-- Output: She will respond shortly. --}} ``` ### Select ordinal format The `selectordinal` format handles ordinal numbers (1st, 2nd, 3rd, etc.) according to locale rules. ```yaml title="resources/lang/en/messages.yaml" anniversary_greeting: > It's my {years, selectordinal, one {#st} two {#nd} few {#rd} other {#th} } anniversary ``` ```edge title="resources/views/profile.edge" {{ t('messages.anniversary_greeting', { years: 2 }) }} {{-- Output: It's my 2nd anniversary --}} ``` The ordinal categories are the same as plural categories (`zero`, `one`, `two`, `few`, `many`, `other`, `=value`). ## Formatting values The i18n package provides methods for formatting values according to locale conventions. These methods use the [Node.js Intl API](https://nodejs.org/dist/latest/docs/api/intl.html) with optimized performance. ### formatNumber Format numeric values using the `Intl.NumberFormat` class. ```ts title="app/services/example_service.ts" import i18nManager from '@adonisjs/i18n/services/main' i18nManager .locale('en') .formatNumber(123456.789, { maximumSignificantDigits: 3 }) // "123,000" ``` See: [Intl.NumberFormat options](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/NumberFormat/NumberFormat#options) ### formatCurrency Format a numeric value as currency. This method sets `style: 'currency'` automatically. ```ts title="app/services/example_service.ts" import i18nManager from '@adonisjs/i18n/services/main' i18nManager .locale('en') .formatCurrency(200, { currency: 'USD' }) // "$200.00" ``` ### formatDate Format a Date object or Luxon DateTime instance using the `Intl.DateTimeFormat` class. ```ts title="app/services/example_service.ts" import i18nManager from '@adonisjs/i18n/services/main' import { DateTime } from 'luxon' i18nManager .locale('en') .formatDate(new Date(), { dateStyle: 'long' }) // "January 10, 2026" i18nManager .locale('en') .formatDate(DateTime.local(), { dateStyle: 'long' }) // "January 10, 2026" ``` See: [Intl.DateTimeFormat options](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/DateTimeFormat/DateTimeFormat#options) ### formatTime Format a date value as a time string. This method sets `timeStyle: 'medium'` by default. ```ts title="app/services/example_service.ts" import i18nManager from '@adonisjs/i18n/services/main' i18nManager .locale('en') .formatTime(new Date()) // "2:48:30 PM" ``` ### formatRelativeTime Format a value as relative time (like "in 2 hours" or "3 days ago") using the `Intl.RelativeTimeFormat` class. ```ts title="app/services/example_service.ts" import { DateTime } from 'luxon' import i18nManager from '@adonisjs/i18n/services/main' const futureDate = DateTime.local().plus({ hours: 2 }) i18nManager .locale('en') .formatRelativeTime(futureDate, 'hours') // "in 2 hours" ``` Use the `'auto'` unit to automatically select the most appropriate unit. ```ts title="app/services/example_service.ts" import { DateTime } from 'luxon' import i18nManager from '@adonisjs/i18n/services/main' const nearFuture = DateTime.local().plus({ hours: 2 }) i18nManager.locale('en').formatRelativeTime(nearFuture, 'auto') // "in 2 hours" const farFuture = DateTime.local().plus({ hours: 200 }) i18nManager.locale('en').formatRelativeTime(farFuture, 'auto') // "in 8 days" ``` See: [Intl.RelativeTimeFormat options](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/RelativeTimeFormat/RelativeTimeFormat#options) ### formatPlural Find the plural category for a number using the `Intl.PluralRules` class. This is useful when you need to handle pluralization in code rather than in translation strings. ```ts title="app/services/example_service.ts" import i18nManager from '@adonisjs/i18n/services/main' i18nManager.locale('en').formatPlural(0) // "other" i18nManager.locale('en').formatPlural(1) // "one" i18nManager.locale('en').formatPlural(2) // "other" ``` See: [Intl.PluralRules options](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/PluralRules/PluralRules#options) ### formatList Format an array of strings as a human-readable list using the `Intl.ListFormat` class. ```ts title="app/services/example_service.ts" import i18nManager from '@adonisjs/i18n/services/main' i18nManager .locale('en') .formatList(['Me', 'myself', 'I'], { type: 'conjunction' }) // "Me, myself, and I" i18nManager .locale('en') .formatList(['5 hours', '3 minutes'], { type: 'unit' }) // "5 hours, 3 minutes" ``` See: [Intl.ListFormat options](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/ListFormat/ListFormat#options) ### formatDisplayNames Format codes for currencies, languages, regions, and calendars as human-readable names using the `Intl.DisplayNames` class. ```ts title="app/services/example_service.ts" import i18nManager from '@adonisjs/i18n/services/main' i18nManager .locale('en') .formatDisplayNames('INR', { type: 'currency' }) // "Indian Rupee" i18nManager .locale('en') .formatDisplayNames('en-US', { type: 'language' }) // "American English" ``` See: [Intl.DisplayNames options](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/DisplayNames/DisplayNames#options) ## Configuring the i18n Ally VSCode extension The [i18n Ally](https://marketplace.visualstudio.com/items?itemName=Lokalise.i18n-ally) extension for VSCode provides inline translation previews, missing translation detection, and quick editing. To configure it for AdonisJS, create two files in your `.vscode` directory. ```sh mkdir -p .vscode touch .vscode/i18n-ally-custom-framework.yml touch .vscode/settings.json ``` ```json title=".vscode/settings.json" { "i18n-ally.localesPaths": [ "resources/lang" ], "i18n-ally.keystyle": "nested", "i18n-ally.namespace": true, "i18n-ally.editor.preferEditor": true, "i18n-ally.refactor.templates": [ { "templates": [ "{{ t('{key}'{args}) }}" ], "include": [ "**/*.edge" ] } ] } ``` ```yaml title=".vscode/i18n-ally-custom-framework.yml" languageIds: - edge usageMatchRegex: - "[^\\w\\d]t\\(['\"`]({key})['\"`]" sortKeys: true ``` ## Listening for missing translations Subscribe to the `i18n:missing:translation` event to track missing translations in your application. This is useful for logging or alerting during development. ```ts title="start/events.ts" import emitter from '@adonisjs/core/services/emitter' emitter.on('i18n:missing:translation', function (event) { console.log(event.identifier) // The translation key that was missing console.log(event.hasFallback) // Whether a fallback was used console.log(event.locale) // The locale that was requested }) ``` ## Reloading translations The i18n package loads translation files at startup and caches them in memory. If you modify translation files while the application is running, use the `reloadTranslations` method to refresh the cache. ```ts title="app/controllers/admin_controller.ts" import i18nManager from '@adonisjs/i18n/services/main' export default class AdminController { async refreshTranslations() { await i18nManager.reloadTranslations() } } ``` ## Advanced: Creating a custom translation loader A translation loader is responsible for loading translations from a persistent store. The default filesystem loader reads from `resources/lang`, but you can create custom loaders to read from a database, remote API, or any other source. A loader must implement the `TranslationsLoaderContract` interface with a `load` method that returns translations grouped by locale. ```ts title="app/i18n/db_loader.ts" import type { LoaderFactory, TranslationsLoaderContract, } from '@adonisjs/i18n/types' /** * Configuration options for the loader */ export type DbLoaderConfig = { connection: string tableName: string } /** * The loader implementation */ export class DbLoader implements TranslationsLoaderContract { constructor(public config: DbLoaderConfig) {} async load() { /** * Query your database here and return translations * grouped by locale with flattened keys */ return { en: { 'messages.greeting': 'Hello world', 'messages.farewell': 'Goodbye', }, fr: { 'messages.greeting': 'Bonjour le monde', 'messages.farewell': 'Au revoir', } } } } /** * Factory function to reference the loader in config */ export function dbLoader(config: DbLoaderConfig): LoaderFactory { return () => { return new DbLoader(config) } } ``` ### Using the custom loader Reference the loader in your config file using the factory function. ```ts title="config/i18n.ts" import { defineConfig } from '@adonisjs/i18n' import { dbLoader } from '#app/i18n/db_loader' const i18nConfig = defineConfig({ defaultLocale: 'en', formatter: formatters.icu(), loaders: [ // [!code ++:4] dbLoader({ connection: 'pg', tableName: 'translations' }) ] }) export default i18nConfig ``` ## Advanced: Creating a custom translation formatter A translation formatter processes translation strings according to a specific format. The default ICU formatter handles ICU message syntax, but you can create custom formatters for other formats like Fluent. A formatter must implement the `TranslationsFormatterContract` interface with a `format` method. ```ts title="app/i18n/fluent_formatter.ts" import type { FormatterFactory, TranslationsFormatterContract, } from '@adonisjs/i18n/types' /** * The formatter implementation */ export class FluentFormatter implements TranslationsFormatterContract { format( message: string, locale: string, data?: Record ): string { /** * Process the message using your chosen format * and return the formatted string */ return message // Your formatting logic here } } /** * Factory function to reference the formatter in config */ export function fluentFormatter(): FormatterFactory { return () => { return new FluentFormatter() } } ``` ### Using the custom formatter Reference the formatter in your config file using the factory function. ```ts title="config/i18n.ts" import { defineConfig } from '@adonisjs/i18n' import { fluentFormatter } from '#app/i18n/fluent_formatter' const i18nConfig = defineConfig({ defaultLocale: 'en', // [!code ++:1] formatter: fluentFormatter() }) export default i18nConfig ``` --- # Atomic Locks This guide covers atomic locks in AdonisJS applications. You will learn how to: - Install and configure the `@adonisjs/lock` package - Create and release locks to protect critical sections - Use different acquisition methods for various scenarios - Extend lock expiry for long-running operations - Share locks between different processes ## Overview Atomic locks prevent race conditions when multiple processes or parts of your codebase might perform concurrent actions on the same resource. Consider a payment processing scenario where a queue job could be enqueued twice due to a network retry. Without proper locking, the system might charge the user twice. Atomic locks ensure that only one process can execute the critical section at a time. The `@adonisjs/lock` package is a wrapper over [Verrou](https://verrou.dev), a framework-agnostic locking library created and maintained by the AdonisJS core team. It supports three storage backends: Redis, database, and memory. ## Installation Install and configure the package using the following command. ```sh node ace add @adonisjs/lock ``` :::disclosure{title="See steps performed by the add command"} 1. Installs the `@adonisjs/lock` package using the detected package manager. 2. Registers the following service provider inside the `adonisrc.ts` file. ```ts title="adonisrc.ts" { providers: [ // ...other providers () => import('@adonisjs/lock/lock_provider') ] } ``` 3. Creates the `config/lock.ts` file. 4. Defines the `LOCK_STORE` environment variable and its validation inside the `start/env.ts` file. 5. Creates a database migration for the locks table (if using the database store). ::: ## Configuration The configuration is stored in the `config/lock.ts` file. ```ts title="config/lock.ts" import env from '#start/env' import { defineConfig, stores } from '@adonisjs/lock' const lockConfig = defineConfig({ default: env.get('LOCK_STORE'), stores: { /** * Redis store to manage locks. * Requires the @adonisjs/redis package. */ redis: stores.redis({}), /** * Database store to manage locks. * Requires the @adonisjs/lucid package. */ database: stores.database({ tableName: 'locks' }), /** * Memory store could be used during testing. */ memory: stores.memory() }, }) export default lockConfig declare module '@adonisjs/lock/types' { export interface LockStoresList extends InferLockStores {} } ``` ### Redis store The `redis` store has a peer dependency on the `@adonisjs/redis` package. You must [configure the Redis package](../database/redis.md) before using the Redis store. The `connectionName` is a reference to the connection defined within the `config/redis.ts` file. If not defined, the default Redis connection is used. ```ts title="config/lock.ts" { redis: stores.redis({ connectionName: 'main', }), } ``` ### Database store The `database` store has a peer dependency on the `@adonisjs/lucid` package. You must [configure Lucid](../database/lucid.md) before using the database store. The `connectionName` is a reference to a database connection defined within the `config/database.ts` file. If not defined, the default database connection is used. ```ts title="config/lock.ts" { database: stores.database({ connectionName: 'postgres', tableName: 'my_locks', }), } ``` The data is stored within the `locks` table. A migration for this table is automatically created during installation. However, if needed, you can manually create a migration with the following contents. :::disclosure{title="Migration file contents"} ```ts title="database/migrations/xxxx_create_locks_table.ts" import { BaseSchema } from '@adonisjs/lucid/schema' export default class extends BaseSchema { protected tableName = 'locks' async up() { this.schema.createTable(this.tableName, (table) => { table.string('key', 255).notNullable().primary() table.string('owner').notNullable() table.bigint('expiration').unsigned().nullable() }) } async down() { this.schema.dropTable(this.tableName) } } ``` ::: ### Environment variables The default store is configured using the `LOCK_STORE` environment variable. ```dotenv title=".env" LOCK_STORE=redis ``` ## Creating locks Create a lock using the `createLock` method from the lock manager service. The method accepts a unique key that identifies the resource being locked and a TTL (time-to-live) that defines how long the lock remains valid. ```ts import lockManager from '@adonisjs/lock/services/main' const lock = lockManager.createLock('processing_payment:order:42', '30s') ``` The lock key should uniquely identify the resource being protected. A common pattern is to use a descriptive prefix followed by an identifier, such as `processing_payment:order:${orderId}` or `sending_email:user:${userId}`. The TTL accepts either a time expression string (like `'10s'`, `'5m'`, or `'1h'`) or a number in milliseconds. The TTL acts as a safety mechanism. If a process crashes while holding a lock, the lock will automatically expire after the TTL, preventing deadlocks. ## Acquiring locks The package provides several methods for acquiring locks, each suited to different scenarios. ### Running code within a lock The `run` method is the recommended way to execute code within a lock. It acquires the lock, executes your callback, and automatically releases the lock when the callback completes (or throws an error). ```ts title="app/services/payment_service.ts" import lockManager from '@adonisjs/lock/services/main' export default class PaymentService { async processPayment(order: Order) { const lock = lockManager.createLock( `processing_payment:order:${order.id}`, '30s' ) const [acquired, result] = await lock.run(async () => { /** * This callback only executes after acquiring the lock. * The lock is automatically released when the callback * completes or throws an error. */ const charge = await this.chargeCustomer(order) await order.merge({ status: 'paid', chargeId: charge.id }).save() return charge }) if (!acquired) { return { success: false, message: 'Payment already in progress' } } return { success: true, charge: result } } } ``` By default, `run` waits indefinitely until the lock becomes available. You can configure this behavior using options. ### Running immediately or not at all The `runImmediately` method attempts to acquire the lock without waiting. If the lock is already held by another process, the callback does not execute. ```ts const [acquired, result] = await lock.runImmediately(async () => { // Only runs if lock was acquired immediately }) if (!acquired) { // Lock was not available } ``` ### Manual lock management For more control over the lock lifecycle, use the `acquire` and `release` methods directly. When using manual acquisition, you must ensure the lock is released, even if an error occurs. ```ts const acquired = await lock.acquire() if (acquired) { try { // Perform protected operations } finally { await lock.release() } } ``` The `acquireImmediately` method attempts to acquire the lock without waiting and returns `true` if successful. ```ts const acquired = await lock.acquireImmediately() if (!acquired) { // Lock was not available, handle accordingly } ``` :::tip Prefer the `run` method over manual acquisition when possible. It handles lock release automatically, including when exceptions occur, which prevents accidental lock leaks. ::: ## Lock options When acquiring a lock, you can configure retry behavior and timeouts. ::::options :::option{name="timeout"} Maximum time to wait before giving up on acquiring the lock. Accepts a time expression string or milliseconds. ```ts await lock.acquire({ timeout: '5s' }) ``` ::: :::option{name="attempts"} Maximum number of retry attempts before throwing an error. ```ts await lock.acquire({ attempts: 3 }) ``` ::: :::option{name="delay"} Delay between retry attempts. Accepts a time expression string or milliseconds. ```ts await lock.acquire({ delay: '100ms' }) ``` ::: :::: You can combine these options for fine-grained control. ```ts const acquired = await lock.acquire({ timeout: '10s', attempts: 5, delay: '500ms' }) ``` ## Checking lock state You can inspect the current state of a lock using the following methods. ```ts const lock = lockManager.createLock('my_resource', '30s') /** * Check if the lock is currently held by any process. */ const locked = await lock.isLocked() /** * Check if the lock has expired. */ const expired = await lock.isExpired() /** * Get the remaining time in milliseconds before the lock expires. * Returns null if the lock is not held. */ const remaining = await lock.getRemainingTime() ``` ## Extending locks For long-running operations that might exceed the initial TTL, you can extend the lock duration. This is useful when you cannot predict exactly how long an operation will take. ```ts const lock = lockManager.createLock('long_running_task', '10s') await lock.acquire() try { for (const item of largeDataset) { await processItem(item) /** * Extend the lock by another 10 seconds to prevent * expiration during processing. */ await lock.extend('10s') } } finally { await lock.release() } ``` ## Sharing locks between processes In distributed systems, you might need to acquire a lock in one process and release it in another. The `serialize` method converts a lock into a string that can be stored and restored elsewhere. ```ts title="app/jobs/start_processing.ts" import lockManager from '@adonisjs/lock/services/main' const lock = lockManager.createLock('batch_job:123', '5m') await lock.acquire() /** * Serialize the lock for storage. This string contains * all information needed to restore the lock. */ const serialized = lock.serialize() /** * Store the serialized lock (e.g., in a database or cache) * for retrieval by another process. */ await redis.set('batch_job:123:lock', serialized) ``` In another process, restore the lock using `restoreLock`. ```ts title="app/jobs/finish_processing.ts" import lockManager from '@adonisjs/lock/services/main' const serialized = await redis.get('batch_job:123:lock') /** * Restore the lock from its serialized form. * This creates a lock instance with the same owner, * allowing this process to release it. */ const lock = lockManager.restoreLock(serialized) try { // Complete the processing } finally { await lock.release() } ``` ## See also - [Verrou documentation](https://verrou.dev) for advanced features and detailed API reference - [Redis](../database/redis.md) for setting up the Redis store - [Lucid ORM](../database/lucid.md) for setting up the database store --- # Logger This guide covers logging in AdonisJS applications. You will learn how to: - Write logs during HTTP requests using the request-aware logger - Configure pretty-printed logs for development and file-based logs for production - Define multiple loggers for different parts of your application - Inject the logger into services using dependency injection - Create child loggers that inherit context from their parent - Protect sensitive data from appearing in log output ## Overview AdonisJS includes an inbuilt logger for writing logs to the terminal, files, and external services. Under the hood, the logger uses [Pino](https://getpino.io), one of the fastest logging libraries in the Node.js ecosystem. Logs are produced in the [NDJSON format](https://github.com/ndjson/ndjson-spec), making them easy to parse and process with standard tooling. The logger integrates deeply with AdonisJS. During HTTP requests, each request automatically gets its own logger instance that includes the request ID in every log entry, making it straightforward to trace logs back to specific requests. :::note This guide focuses on logging during HTTP requests. For CLI applications, see the [Ace ANSI logger documentation](../ace/tui.md#displaying-log-messages) which provides terminal-friendly colored output designed for command-line tools. ::: ## Writing your first log Import the logger service and call any of the logging methods to write a message. During development, logs appear in your terminal with pretty formatting that includes timestamps, colors, and readable structure. ```ts title="start/routes.ts" import router from '@adonisjs/core/services/router' import logger from '@adonisjs/core/services/logger' router.get('/', async () => { logger.info('Processing home page request') return { hello: 'world' } }) ``` When you visit the route, you'll see output like this in your terminal: ``` [10:24:36.842] INFO: Processing home page request ``` The logger provides methods for each log level, from most to least verbose: ```ts title="app/controllers/posts_controller.ts" import logger from '@adonisjs/core/services/logger' export default class PostsController { async store() { logger.trace({ config }, 'Using config') // Most verbose, for tracing execution logger.debug('User details: %o', { id: 1 }) // Debug information logger.info('Creating new post') // General information logger.warn('Rate limit approaching') // Warning conditions logger.error({ err }, 'Failed to save post') // Error conditions logger.fatal({ err }, 'Database connection lost') // Critical failures } } ``` ### Adding context to logs Pass an object as the first argument to include additional data in the log entry. The object properties are merged into the JSON output. ```ts const user = { id: 1, email: 'virk@adonisjs.com' } logger.info({ user }, 'User logged in') ``` When logging errors, use the `err` key so Pino's built-in serializer formats the error properly with stack traces: ```ts try { await riskyOperation() } catch (error) { logger.error({ err: error }, 'Operation failed') } ``` ### String interpolation Log messages support printf-style interpolation for embedding values directly in the message string: ```ts logger.info('User %s logged in from %s', username, ipAddress) logger.debug('Request body: %o', requestBody) // %o for objects logger.info('Processing %d items', items.length) // %d for numbers ``` ## Request-aware logging During HTTP requests, use `ctx.logger` instead of importing the logger service directly. The context logger automatically includes the request ID in every log entry, making it easy to correlate all logs from a single request. ```ts title="app/controllers/users_controller.ts" import type { HttpContext } from '@adonisjs/core/http' import User from '#models/user' export default class UsersController { async show({ logger, params }: HttpContext) { logger.info('Fetching user by id %s', params.id) const user = await User.find(params.id) if (!user) { logger.warn('User not found') return { error: 'Not found' } } logger.info('User retrieved successfully') return user } } ``` The output includes the request ID, allowing you to filter logs for a specific request: ``` [10:24:36.842] INFO (request_id=cjkl3402k0001...): Fetching user by id 42 [10:24:36.901] INFO (request_id=cjkl3402k0001...): User retrieved successfully ``` ## Configuring the logger The logger configuration lives in `config/logger.ts`. The default setup uses pretty-printed output in development and structured JSON in production. ```ts title="config/logger.ts" import env from '#start/env' import app from '@adonisjs/core/services/app' import { defineConfig, syncDestination, targets } from '@adonisjs/core/logger' const loggerConfig = defineConfig({ default: 'app', loggers: { app: { enabled: true, name: env.get('APP_NAME'), level: env.get('LOG_LEVEL'), destination: !app.inProduction ? await syncDestination() : undefined, transport: { targets: [targets.file({ destination: 1 })], }, }, }, }) export default loggerConfig declare module '@adonisjs/core/types' { export interface LoggersList extends InferLoggers {} } ``` ### Understanding the configuration The `syncDestination()` helper configures synchronous, pretty-printed output for development. By default, Pino writes logs asynchronously for better performance, but this can make it harder to correlate logs with the code that produced them during debugging. The synchronous destination writes logs inline as your code executes, with human-readable formatting. In production, the `destination` is left as `undefined`, which means logs flow through the configured transport targets. The `targets.file({ destination: 1 })` target writes JSON logs to stdout (file descriptor 1), which is the standard approach for containerized deployments where a log aggregator collects stdout. ### Configuration reference | Property | Description | |----------|-------------| | `default` | The name of the logger to use when calling `logger.info()` without specifying a logger | | `enabled` | Set to `false` to disable the logger entirely | | `name` | A name included in every log entry, useful for identifying the source application | | `level` | The minimum level to log. Messages below this level are ignored | | `destination` | A custom destination stream. Use `syncDestination()` for synchronous pretty output | | `transport` | Configuration for Pino transports that process and route logs | ### Log levels The logger supports six levels, ordered from most to least verbose. When you set a level, the logger produces logs at that level and above. | Level | Value | Description | |-------|-------|-------------| | `trace` | 10 | Extremely detailed tracing information | | `debug` | 20 | Debug information useful during development | | `info` | 30 | General operational information | | `warn` | 40 | Warning conditions that should be reviewed | | `error` | 50 | Error conditions that need attention | | `fatal` | 60 | Critical failures that require immediate action | Set the level in your `.env` file: ```dotenv title=".env" LOG_LEVEL=debug ``` ### Writing logs to a file To write logs to a file instead of stdout, configure the `targets.file()` helper with a file path: ```ts title="config/logger.ts" transport: { targets: [ targets.file({ destination: '/var/log/apps/adonisjs.log' }) ], } ``` ### File rotation Pino does not include built-in file rotation. Use either a system tool like [logrotate](https://getpino.io/#/docs/help?id=rotate) or the [pino-roll](https://github.com/feugy/pino-roll) package. ```sh npm i pino-roll ``` ```ts title="config/logger.ts" transport: { targets: [ { target: 'pino-roll', level: 'info', options: { file: '/var/log/apps/adonisjs.log', frequency: 'daily', mkdir: true, }, }, ], } ``` ### Defining targets conditionally Use the `targets()` helper to build the targets array with conditional logic. This is cleaner than spreading arrays with ternary operators. ```ts title="config/logger.ts" import app from '@adonisjs/core/services/app' import { defineConfig, targets } from '@adonisjs/core/logger' export default defineConfig({ default: 'app', loggers: { app: { enabled: true, name: env.get('APP_NAME'), level: env.get('LOG_LEVEL'), transport: { targets: targets() .pushIf(!app.inProduction, targets.pretty()) .pushIf(app.inProduction, targets.file({ destination: 1 })) .toArray(), }, }, }, }) ``` The `pushIf` method only adds the target when the condition is true, keeping your configuration readable. ## Using multiple loggers Define multiple loggers in your configuration when different parts of your application need separate logging behavior. For example, you might want payment-related logs to go to a separate file with a different retention policy. ```ts title="config/logger.ts" export default defineConfig({ default: 'app', loggers: { app: { enabled: true, name: env.get('APP_NAME'), level: env.get('LOG_LEVEL'), destination: !app.inProduction ? await syncDestination() : undefined, transport: { targets: [targets.file({ destination: 1 })], }, }, payments: { enabled: true, name: 'payments', level: 'info', transport: { targets: [ targets.file({ destination: '/var/log/apps/payments.log' }), ], }, }, }, }) ``` Access a specific logger using the `logger.use()` method: ```ts title="app/services/payment_service.ts" import logger from '@adonisjs/core/services/logger' export default class PaymentService { async processPayment(amount: number) { const paymentLogger = logger.use('payments') paymentLogger.info({ amount }, 'Processing payment') // ... payment logic paymentLogger.info('Payment completed successfully') } } ``` Calling `logger.use()` without arguments returns the default logger. ## Dependency injection When using dependency injection, type-hint the `Logger` class and the IoC container resolves an instance of the default logger. If the class is constructed during an HTTP request, the container automatically injects the request-aware logger with the request ID included. ```ts title="app/services/user_service.ts" import { inject } from '@adonisjs/core' import { Logger } from '@adonisjs/core/logger' import User from '#models/user' @inject() export default class UserService { constructor(protected logger: Logger) {} async find(userId: string | number) { this.logger.info('Fetching user by id %s', userId) return User.find(userId) } } ``` ## Child loggers A child logger inherits configuration and bindings from its parent while allowing you to add additional context. This is useful when you want all logs from a particular operation to include shared metadata. ```ts import logger from '@adonisjs/core/services/logger' const orderLogger = logger.child({ orderId: 'order_123' }) orderLogger.info('Processing order') // Includes orderId orderLogger.info('Validating items') // Includes orderId orderLogger.info('Order complete') // Includes orderId ``` Every log entry from `orderLogger` automatically includes the `orderId` field. You can also override the log level for a child logger: ```ts const verboseLogger = logger.child({}, { level: 'trace' }) ``` ## Conditional logging If computing data for a log message is expensive, check whether the level is enabled before doing the work: ```ts import logger from '@adonisjs/core/services/logger' if (logger.isLevelEnabled('debug')) { const data = await computeExpensiveDebugData() logger.debug(data, 'Debug information') } ``` The `ifLevelEnabled` method provides a callback-based alternative: ```ts logger.ifLevelEnabled('debug', async () => { const data = await computeExpensiveDebugData() logger.debug(data, 'Debug information') }) ``` ## Hiding sensitive values Logs can inadvertently expose sensitive data. Use the `redact` option to automatically hide values for specific keys. Under the hood, this uses the [fast-redact](https://github.com/davidmarkclements/fast-redact) package. ```ts title="config/logger.ts" loggers: { app: { enabled: true, name: env.get('APP_NAME'), level: env.get('LOG_LEVEL'), redact: { paths: ['password', '*.password', 'creditCard'], }, }, } ``` ```ts logger.info({ username: 'virk', password: 'secret123' }, 'User signup') // Output: {"username":"virk","password":"[Redacted]","msg":"User signup"} ``` Customize the placeholder or remove the keys entirely: ```ts title="config/logger.ts" redact: { paths: ['password', '*.password'], censor: '[PRIVATE]', } // Or remove the property entirely redact: { paths: ['password'], remove: true, } ``` ### Using the Secret class An alternative to redaction is wrapping sensitive values in the `Secret` class. The logger automatically redacts any `Secret` instance. See also: [Secret class documentation](../../reference/helpers.md#secret) ```ts import { Secret } from '@adonisjs/core/helpers' const password = new Secret(request.input('password')) logger.info({ username, password }, 'User signup') // Output: {"username":"virk","password":"[redacted]","msg":"User signup"} ``` ## Pino statics The `@adonisjs/core/logger` module re-exports Pino's static methods and properties for advanced use cases. See the [Pino documentation](https://getpino.io/#/docs/api?id=statics) for details on these exports. ```ts import { multistream, destination, transport, stdSerializers, stdTimeFunctions, symbols, pinoVersion, } from '@adonisjs/core/logger' ``` --- # Mail This guide covers sending emails from your AdonisJS application. You will learn how to: - Configure mail transports for services like SMTP, Resend, Mailgun, and SES - Send emails using the fluent Message API - Queue emails for background delivery - Organize emails into reusable mail classes - Add attachments, embed images, and include calendar invites - Test email functionality with the fake mailer ## Overview The `@adonisjs/mail` package provides a unified API for sending emails through various providers. Built on top of [Nodemailer](https://nodemailer.com/), it adds a fluent configuration API, support for organizing emails as classes, an extensive testing API, and background email delivery through messengers. The package introduces two key concepts. A **transport** is the underlying delivery mechanism (SMTP server, API service like Resend or Mailgun). A **mailer** is a configured instance of a transport that you use to send emails. You can configure multiple mailers in your application, each using different transports or the same transport with different settings, and switch between them at runtime. ## Installation Install and configure the package using the following command: ```sh node ace add @adonisjs/mail ``` You can pre-select transports during installation: ```sh node ace add @adonisjs/mail --transports=resend --transports=smtp ``` :::disclosure{title="See steps performed by the add command"} 1. Installs the `@adonisjs/mail` package using the detected package manager. 2. Registers the following service provider and command inside the `adonisrc.ts` file. ```ts title="adonisrc.ts" { commands: [ // ...other commands () => import('@adonisjs/mail/commands') ], providers: [ // ...other providers () => import('@adonisjs/mail/mail_provider') ] } ``` 3. Creates the `config/mail.ts` file. 4. Defines the environment variables and their validations for the selected mail services. ::: ## Configuration The mail configuration lives in `config/mail.ts`. This file defines your mailers, default sender addresses, and transport settings. See also: [Config stub](https://github.com/adonisjs/mail/blob/-/stubs/config/mail.stub) ```ts title="config/mail.ts" import env from '#start/env' import { defineConfig, transports } from '@adonisjs/mail' const mailConfig = defineConfig({ /** * The mailer to use when none is specified */ default: 'smtp', /** * Global "from" address used when not set on individual emails */ from: { address: 'hello@example.com', name: 'My App', }, /** * Global "reply-to" address used when not set on individual emails */ replyTo: { address: 'support@example.com', name: 'My App Support', }, /** * Configure one or more mailers. Each mailer uses a transport * and can have its own settings. */ mailers: { smtp: transports.smtp({ host: env.get('SMTP_HOST'), port: env.get('SMTP_PORT'), }), resend: transports.resend({ key: env.get('RESEND_API_KEY'), baseUrl: 'https://api.resend.com', }), }, }) export default mailConfig ``` | Option | Description | |--------|-------------| | `default` | The mailer to use when you call `mail.send()` without specifying one. | | `from` | Global sender address. Used unless overridden on individual emails. | | `replyTo` | Global reply-to address. Used unless overridden on individual emails. | | `mailers` | An object containing your configured mailers. Each key is a mailer name, each value is a transport configuration. | ## Transport configuration Each transport accepts provider-specific options. The following sections document the available transports and their configuration. See also: [TypeScript types for config object](https://github.com/adonisjs/mail/blob/10.x/src/types.ts#L243) :::disclosure{title="SMTP"} SMTP configuration options are forwarded directly to Nodemailer. See also: [Nodemailer SMTP documentation](https://nodemailer.com/smtp) ```ts title="config/mail.ts" { mailers: { smtp: transports.smtp({ host: env.get('SMTP_HOST'), port: env.get('SMTP_PORT'), secure: false, auth: { type: 'login', user: env.get('SMTP_USERNAME'), pass: env.get('SMTP_PASSWORD') }, tls: {}, ignoreTLS: false, requireTLS: false, pool: false, maxConnections: 5, maxMessages: 100, }) } } ``` ::: :::disclosure{title="Resend"} Configuration options are sent to Resend's [`/emails`](https://resend.com/docs/api-reference/emails/send-email) API endpoint. ```ts title="config/mail.ts" { mailers: { resend: transports.resend({ baseUrl: 'https://api.resend.com', key: env.get('RESEND_API_KEY'), /** * Optional: Can be overridden at runtime */ tags: [ { name: 'category', value: 'confirm_email' } ] }) } } ``` ::: :::disclosure{title="Mailgun"} Configuration options are sent to Mailgun's [`/messages.mime`](https://documentation.mailgun.com/docs/mailgun/api-reference/send/mailgun/messages/post-v3--domain-name--messages-mime) API endpoint. ```ts title="config/mail.ts" { mailers: { mailgun: transports.mailgun({ baseUrl: 'https://api.mailgun.net/v3', key: env.get('MAILGUN_API_KEY'), domain: env.get('MAILGUN_DOMAIN'), /** * Optional: Can be overridden at runtime */ oDkim: true, oTags: ['transactional', 'adonisjs_app'], oDeliverytime: new Date(2024, 8, 18), oTestMode: false, oTracking: false, oTrackingClick: false, oTrackingOpens: false, headers: {}, variables: { appId: '', userId: '', } }) } } ``` ::: :::disclosure{title="SparkPost"} Configuration options are sent to SparkPost's [`/transmissions`](https://developers.sparkpost.com/api/transmissions/#header-request-body) API endpoint. ```ts title="config/mail.ts" { mailers: { sparkpost: transports.sparkpost({ baseUrl: 'https://api.sparkpost.com/api/v1', key: env.get('SPARKPOST_API_KEY'), /** * Optional: Can be overridden at runtime */ startTime: new Date(), openTracking: false, clickTracking: false, initialOpen: false, transactional: true, sandbox: false, skipSuppression: false, ipPool: '', }) } } ``` ::: :::disclosure{title="Amazon SES"} SES configuration options are forwarded to Nodemailer. You must install the AWS SDK separately. ```sh npm i @aws-sdk/client-sesv2 ``` See also: [Nodemailer SES documentation](https://nodemailer.com/transports/ses) ```ts title="config/mail.ts" { mailers: { ses: transports.ses({ /** * AWS SDK configuration */ apiVersion: '2010-12-01', region: 'us-east-1', credentials: { accessKeyId: env.get('AWS_ACCESS_KEY_ID'), secretAccessKey: env.get('AWS_SECRET_ACCESS_KEY'), }, /** * Nodemailer-specific options */ sendingRate: 10, maxConnections: 5, }) } } ``` ::: ## Sending your first email Import the mail service and call the `send` method. The callback receives a `Message` instance for configuring the email. ```ts title="app/controllers/users_controller.ts" import type { HttpContext } from '@adonisjs/core/http' import mail from '@adonisjs/mail/services/main' import User from '#models/user' export default class UsersController { async store({ request }: HttpContext) { const user = await User.create(request.all()) /** * Send a welcome email after creating the user. * The callback configures the message properties. */ await mail.send((message) => { message .to(user.email) .from('welcome@example.com') .subject('Welcome to our app!') .htmlView('emails/welcome', { user }) }) return user } } ``` The `mail.send` method delivers the email immediately using the default mailer. For production applications with high traffic, you should queue emails for background delivery instead. ## Configuring the message The `Message` class provides a fluent API for building emails. You receive an instance in the callback passed to `mail.send` or `mail.sendLater`. ### Subject and sender ```ts title="app/controllers/orders_controller.ts" await mail.send((message) => { message .subject('Your order has shipped') .from('orders@example.com') }) ``` The `from` method accepts either a string or an object with address and name: ```ts title="app/controllers/orders_controller.ts" await mail.send((message) => { message.from({ address: 'orders@example.com', name: 'Acme Store' }) }) ``` ### Recipients Use `to`, `cc`, and `bcc` to set recipients. Each method accepts a string, an object, or an array. ```ts title="app/controllers/reports_controller.ts" await mail.send((message) => { message .to(user.email) .cc(user.manager.email) .bcc('audit@example.com') }) ``` ```ts title="app/controllers/reports_controller.ts" await mail.send((message) => { message .to({ address: user.email, name: user.fullName, }) .cc([ { address: 'team-lead@example.com', name: 'Team Lead' }, { address: 'pm@example.com', name: 'Project Manager' } ]) }) ``` Set the reply-to address using the `replyTo` method: ```ts title="app/controllers/support_controller.ts" await mail.send((message) => { message .from('noreply@example.com') .replyTo('support@example.com') }) ``` ### Email contents Define HTML and plain text content using `html` and `text` methods for inline content, or `htmlView` and `textView` to render Edge templates. ```ts title="app/controllers/auth_controller.ts" await mail.send((message) => { /** * Inline content works for simple emails */ message.html(`

Reset your password

Click here to reset your password.

`) message.text(` Reset your password Visit ${resetUrl} to reset your password. `) }) ``` For most emails, Edge templates provide better organization: ```sh node ace make:view emails/password_reset_html node ace make:view emails/password_reset_text ``` ```ts title="app/controllers/auth_controller.ts" await mail.send((message) => { /** * Render Edge templates, passing data as the second argument */ message.htmlView('emails/password_reset_html', { user, resetUrl }) message.textView('emails/password_reset_text', { user, resetUrl }) }) ``` See also: [Configuring Edge](../frontend/edgejs.md) ### Using MJML for responsive emails [MJML](https://mjml.io/) is a markup language that compiles to responsive HTML email markup. Install the package and use the `@mjml` Edge tag. ```sh npm i mjml ``` ```edge title="resources/views/emails/welcome_html.edge" @mjml() Welcome, {{ user.name }}! Verify Email @end ``` Pass [MJML options](https://documentation.mjml.io/#inside-node-js) as props to the tag: ```edge title="resources/views/emails/welcome_html.edge" @mjml({ keepComments: false, fonts: { Lato: 'https://fonts.googleapis.com/css?family=Lato:400,500,700' } }) {{-- MJML content --}} @end ``` ## Queueing emails Sending emails synchronously blocks request processing while waiting for the mail provider's response. For better performance, queue emails for background delivery using `mail.sendLater`. ```ts title="app/controllers/users_controller.ts" import mail from '@adonisjs/mail/services/main' export default class UsersController { async store({ request }: HttpContext) { const user = await User.create(request.all()) /** * Queue the email instead of sending immediately. * The request completes without waiting for delivery. */ await mail.sendLater((message) => { message .to(user.email) .from('welcome@example.com') .subject('Welcome!') .htmlView('emails/welcome', { user }) }) return user } } ``` By default, the mail messenger uses an in-memory queue. This means queued emails are lost if your process terminates before sending them. :::warning The in-memory messenger is suitable for development but not recommended for production. If your application crashes or restarts with pending emails in the queue, those emails will never be sent. Use a persistent queue like BullMQ for production deployments. ::: ### Using BullMQ for persistent queuing For production applications, configure a persistent queue using BullMQ and Redis. This ensures emails survive process restarts. ```sh npm i bullmq ``` Create a custom messenger that stores email jobs in Redis: ```ts title="start/mail.ts" import { Queue } from 'bullmq' import mail from '@adonisjs/mail/services/main' const emailsQueue = new Queue('emails') mail.setMessenger((mailer) => { return { async queue(mailMessage, config) { /** * Store the compiled message, config, and mailer name. * A worker process will pick this up and send it. */ await emailsQueue.add('send_email', { mailMessage, config, mailerName: mailer.name, }) } } }) ``` Create a worker to process the queue. Run this in a separate process: ```ts title="workers/mail_worker.ts" import { Worker } from 'bullmq' import mail from '@adonisjs/mail/services/main' new Worker('emails', async (job) => { if (job.name === 'send_email') { const { mailMessage, config, mailerName } = job.data /** * Send the pre-compiled message using the specified mailer */ await mail.use(mailerName).sendCompiled(mailMessage, config) } }) ``` ## Switching between mailers Use `mail.use()` to send emails through a specific mailer instead of the default. ```ts title="app/controllers/notifications_controller.ts" import mail from '@adonisjs/mail/services/main' /** * Send transactional emails through the default mailer */ await mail.send((message) => { message.subject('Order confirmed') }) /** * Send marketing emails through Mailgun */ await mail.use('mailgun').send((message) => { message.subject('Weekly newsletter') }) /** * Queue emails through a specific mailer */ await mail.use('resend').sendLater((message) => { message.subject('Your report is ready') }) ``` Mailer instances are cached for the process lifetime. To close a connection and remove it from the cache, use `mail.close()`: ```ts title="app/services/mail_service.ts" import mail from '@adonisjs/mail/services/main' /** * Close the mailgun connection and remove from cache */ await mail.close('mailgun') /** * Next call creates a fresh instance */ mail.use('mailgun') ``` ## Attachments ### File attachments Attach files using the `attach` method with an absolute file path: ```ts title="app/controllers/invoices_controller.ts" import app from '@adonisjs/core/services/app' await mail.send((message) => { message.attach(app.makePath('storage/invoices/inv-001.pdf')) }) ``` Customize the attachment filename and other options: ```ts title="app/controllers/invoices_controller.ts" await mail.send((message) => { message.attach(app.makePath('storage/invoices/inv-001.pdf'), { filename: 'invoice-october-2024.pdf', contentType: 'application/pdf', }) }) ``` | Option | Description | |--------|-------------| | `filename` | Display name for the attachment. Defaults to the file's basename. | | `contentType` | MIME type. Inferred from extension if not set. | | `contentDisposition` | Either `attachment` (default) or `inline`. | | `headers` | Custom headers as key-value pairs. | ### Attachments from streams and buffers Use `attachData` for dynamic content from streams or buffers: ```ts title="app/controllers/reports_controller.ts" import fs from 'node:fs' await mail.send((message) => { /** * Attach from a readable stream */ message.attachData(fs.createReadStream('./report.pdf'), { filename: 'report.pdf' }) /** * Attach from a buffer */ message.attachData(Buffer.from('Hello world'), { filename: 'greeting.txt', encoding: 'utf-8', }) }) ``` :::warning Do not use `attachData` with `mail.sendLater`. Queued emails are serialized to JSON, so streams cannot be stored and buffers significantly increase storage size. Attempting to queue an email with a stream attachment will fail. Instead, save the file to disk first and use `attach` with the file path. ::: ### Embedding images Embed images directly in HTML content using the `embedImage` helper in your Edge template. This uses CID (Content-ID) attachments, which display reliably across email clients. ```edge title="resources/views/emails/newsletter.edge" Logo ``` The helper returns a `cid:` URL and automatically adds the image as an attachment. For dynamic image data, use `embedImageData`: ```edge title="resources/views/emails/report.edge" ``` ## Calendar invites Attach calendar events using the `icalEvent` method. You can provide raw iCalendar content or use the fluent API. ```ts title="app/controllers/meetings_controller.ts" import { DateTime } from 'luxon' await mail.send((message) => { message.icalEvent((calendar) => { calendar.createEvent({ summary: 'Project kickoff meeting', start: DateTime.now().plus({ days: 1 }).set({ hour: 10 }), end: DateTime.now().plus({ days: 1 }).set({ hour: 11 }), location: 'Conference Room A', }) }, { method: 'REQUEST', filename: 'meeting.ics', }) }) ``` The calendar object is an instance of [ical-generator](https://www.npmjs.com/package/ical-generator). Load invite content from a file or URL: ```ts title="app/controllers/meetings_controller.ts" import app from '@adonisjs/core/services/app' await mail.send((message) => { /** * From a local file */ message.icalEventFromFile( app.resourcesPath('invites/standup.ics'), { method: 'REQUEST', filename: 'standup.ics' } ) /** * From a URL */ message.icalEventFromUrl( 'https://example.com/calendar/event-123.ics', { method: 'REQUEST', filename: 'event.ics' } ) }) ``` ## Custom headers Add custom headers using the `header` method: ```ts title="app/controllers/notifications_controller.ts" await mail.send((message) => { message.header('X-Entity-Ref', 'order-12345') message.header('X-Priority', '1') }) ``` For headers that should not be encoded or folded, use `preparedHeader`: ```ts title="app/controllers/notifications_controller.ts" await mail.send((message) => { message.preparedHeader( 'X-Custom-Data', 'value with special chars or very long content' ) }) ``` ### List headers Helper methods simplify common List-* headers for mailing list functionality: ```ts title="app/controllers/newsletters_controller.ts" await mail.send((message) => { message.listUnsubscribe({ url: 'https://example.com/unsubscribe?token=abc', comment: 'Unsubscribe from this list' }) message.listHelp('support@example.com?subject=help') /** * For other List-* headers */ message.addListHeader('post', 'https://example.com/list/post') }) ``` See also: [Nodemailer list headers documentation](https://nodemailer.com/message/list-headers) ## Class-based emails For complex applications, organize emails into dedicated classes instead of inline callbacks. This improves testability and keeps controllers focused on request handling. ```sh node ace make:mail verify_email ``` ```ts title="app/mails/verify_email.ts" import User from '#models/user' import router from '@adonisjs/core/services/router' import { BaseMail } from '@adonisjs/mail' export default class VerifyEmailNotification extends BaseMail { /** * Set class properties for common message options */ from = 'noreply@example.com' subject = 'Please verify your email address' constructor(private user: User) { super() } /** * Configure the message in the prepare method. * Called automatically before sending. */ prepare() { const verifyUrl = router.makeUrl('email.verify', { token: this.user.verificationToken }) this.message .to(this.user.email) .htmlView('emails/verify_email', { user: this.user, verifyUrl, }) } } ``` Send the email by passing an instance to `mail.send` or `mail.sendLater`: ```ts title="app/controllers/auth_controller.ts" import mail from '@adonisjs/mail/services/main' import VerifyEmailNotification from '#mails/verify_email' export default class AuthController { async register({ request }: HttpContext) { const user = await User.create(request.all()) await mail.sendLater(new VerifyEmailNotification(user)) return { message: 'Check your email to verify your account' } } } ``` | Property/Method | Description | |-----------------|-------------| | `from` | Default sender address. Override with `message.from()` in `prepare`. | | `subject` | Default subject line. Override with `message.subject()` in `prepare`. | | `replyTo` | Default reply-to address. | | `prepare()` | Configure the message. Called automatically before sending. | | `build()` | Inherited from `BaseMail`. Calls `prepare` and compiles the message. | See also: [Make mail command](../../reference/commands.md#makemail) ## Testing The mail package provides a fake mailer for testing email functionality without actually sending emails. ### Using the fake mailer Call `mail.fake()` to intercept all emails. The returned object contains a `mails` property for assertions. ```ts title="tests/functional/users/register.spec.ts" import { test } from '@japa/runner' import mail from '@adonisjs/mail/services/main' import VerifyEmailNotification from '#mails/verify_email' test.group('Users | register', () => { test('sends verification email on registration', async ({ client }) => { /** * Fake the mailer. The `using` keyword automatically * restores the real mailer when the test ends. */ // [!code highlight] using fake = mail.fake() await client .post('/register') .form({ email: 'user@example.com', password: 'secret123' }) /** * Assert the email was sent */ fake.mails.assertSent(VerifyEmailNotification, ({ message }) => { return message .hasTo('user@example.com') .hasSubject('Please verify your email address') }) }) test('does not send password reset when user not found', async ({ client }) => { // [!code highlight] using fake = mail.fake() await client .post('/forgot-password') .form({ email: 'unknown@example.com' }) fake.mails.assertNotSent(PasswordResetNotification) }) }) ``` You can also call `mail.restore()` manually if you need more control over when the real mailer is restored. ### Assertion methods The `mails` object provides these assertion methods: | Method | Description | |--------|-------------| | `assertSent(Mail, finder?)` | Assert an email class was sent. Optional finder callback for additional checks. | | `assertNotSent(Mail, finder?)` | Assert an email class was not sent. | | `assertSentCount(count)` | Assert total number of emails sent. | | `assertSentCount(Mail, count)` | Assert number of emails sent for a specific class. | | `assertNoneSent()` | Assert no emails were sent. | | `assertQueued(Mail, finder?)` | Assert an email was queued via `sendLater`. | | `assertNotQueued(Mail, finder?)` | Assert an email was not queued. | | `assertQueuedCount(count)` | Assert total number of queued emails. | | `assertQueuedCount(Mail, count)` | Assert number of queued emails for a specific class. | | `assertNoneQueued()` | Assert no emails were queued. | ### Testing mail classes directly Test mail classes in isolation by building them without sending: ```ts title="tests/unit/mails/verify_email.spec.ts" import { test } from '@japa/runner' import { UserFactory } from '#database/factories/user_factory' import VerifyEmailNotification from '#mails/verify_email' test.group('VerifyEmailNotification', () => { test('builds correct message', async () => { const user = await UserFactory.create() const email = new VerifyEmailNotification(user) /** * Build the message and render templates */ await email.buildWithContents() /** * Assert message properties */ email.message.assertTo(user.email) email.message.assertFrom('noreply@example.com') email.message.assertSubject('Please verify your email address') /** * Assert rendered content */ email.message.assertHtmlIncludes(`Hello ${user.name}`) email.message.assertHtmlIncludes('/verify/') }) }) ``` ### Accessing sent emails Retrieve the list of sent or queued emails for custom assertions: ```ts title="tests/functional/notifications.spec.ts" using fake = mail.fake() /** * Get all sent emails */ const sentEmails = fake.mails.sent() /** * Get all queued emails */ const queuedEmails = fake.mails.queued() /** * Find a specific email */ const verifyEmail = sentEmails.find((email) => { return email instanceof VerifyEmailNotification }) if (verifyEmail) { verifyEmail.message.assertHtmlIncludes('Verify your email') } ``` ## Custom transports Create custom transports to integrate mail providers not included in the package. A transport wraps a Nodemailer transport and normalizes its response. ```ts title="app/mail/transports/postmark.ts" import nodemailer from 'nodemailer' import postmarkTransport from 'nodemailer-postmark-transport' import { MailResponse } from '@adonisjs/mail' import type { NodeMailerMessage, MailTransportContract } from '@adonisjs/mail/types' export type PostmarkConfig = { auth: { apiKey: string } } export class PostmarkTransport implements MailTransportContract { #config: PostmarkConfig constructor(config: PostmarkConfig) { this.#config = config } async send( message: NodeMailerMessage, config?: PostmarkConfig ): Promise { /** * Create nodemailer transport with merged config */ const transporter = nodemailer.createTransport( postmarkTransport({ ...this.#config, ...config }) ) const response = await transporter.sendMail(message) /** * Return normalized response */ return new MailResponse(response.messageId, response.envelope, response) } } ``` Create a factory function for use in the config file: ```ts title="app/mail/transports/postmark.ts" import type { MailManagerTransportFactory } from '@adonisjs/mail/types' export function postmarkTransport( config: PostmarkConfig ): MailManagerTransportFactory { return () => new PostmarkTransport(config) } ``` Register the transport in your config: ```ts title="config/mail.ts" import env from '#start/env' import { defineConfig } from '@adonisjs/mail' import { postmarkTransport } from '#app/mail/transports/postmark' const mailConfig = defineConfig({ mailers: { postmark: postmarkTransport({ auth: { apiKey: env.get('POSTMARK_API_KEY'), }, }), }, }) ``` ## Custom template engine By default, the mail package uses Edge for rendering email templates. To use a different template engine, override the static `templateEngine` property on the `Message` class. ```ts title="start/mail.ts" import { Message } from '@adonisjs/mail' Message.templateEngine = { async render(templatePath, data) { /** * Use your preferred template engine */ return myTemplateEngine.render(templatePath, data) } } ``` ## Events The mail package emits events during the email lifecycle. See also: [Events reference](../../reference/events.md#mailsending) --- # Queues :::warning The `@adonisjs/queue` package is currently experimental. Its API may change between minor releases until it reaches a stable version. Pin the package version in your `package.json` to avoid unexpected breaking changes during updates. ::: This guide covers background job processing with queues in AdonisJS. You will learn how to: - Install and configure the queue system with Redis or Database backends - Create jobs and dispatch them for background processing - Delay jobs, set priorities, and dispatch in batches - Configure retry strategies with exponential, linear, or fixed backoff - Schedule recurring jobs using cron expressions or intervals - Start workers to process jobs from queues - Test job dispatching with the fake adapter ## Overview Web applications often need to perform tasks that are too slow or resource-intensive to run during an HTTP request. Sending emails, generating reports, processing payments, or resizing images are all examples of work that should happen in the background so your users get an immediate response. The `@adonisjs/queue` package provides a job queue system for AdonisJS, built on top of [@boringnode/queue](https://github.com/boringnode/queue). You define **jobs** as classes with typed payloads, dispatch them from your application code, and run a separate **worker** process that picks up and executes those jobs. The package supports multiple backends. The **Redis** adapter is recommended for production, offering atomic operations and high throughput. The **Database** adapter uses your existing SQL database (PostgreSQL, MySQL, or SQLite) through Lucid. A **Sync** adapter is also available for development and testing, executing jobs immediately without a separate worker. ## Installation Install and configure the package using the following command: ```sh node ace add @adonisjs/queue ``` :::disclosure{title="See steps performed by the add command"} 1. Installs the `@adonisjs/queue` package using the detected package manager. 2. Registers the following service provider, commands, and preload file inside the `adonisrc.ts` file. ```ts title="adonisrc.ts" { commands: [ // ...other commands () => import('@adonisjs/queue/commands') ], providers: [ // ...other providers () => import('@adonisjs/queue/queue_provider') ], preloads: [ // ...other preloads () => import('#start/scheduler') ] } ``` 3. Creates the `config/queue.ts` file. 4. Creates the `start/scheduler.ts` preload file for defining scheduled jobs. 5. Defines the `QUEUE_DRIVER` environment variable and its validation. 6. If you select the database driver, creates a migration to set up queue tables. ::: ## Configuration The configuration file lives at `config/queue.ts`. It defines your adapters, the default adapter to use, worker settings, and the location of your job files. See also: [Config stub](https://github.com/adonisjs/queue/blob/-/stubs/config/queue.stub) ```ts title="config/queue.ts" import env from '#start/env' import { defineConfig, drivers } from '@adonisjs/queue' export default defineConfig({ default: env.get('QUEUE_DRIVER', 'redis'), adapters: { redis: drivers.redis({ connectionName: 'main', }), sync: drivers.sync(), }, worker: { concurrency: 5, idleDelay: '2s', }, locations: ['./app/jobs/**/*.{ts,js}'], }) ``` ::::options :::option{name="default" dataType="string"} The name of the adapter to use by default when dispatching jobs. This value is typically set via the `QUEUE_DRIVER` environment variable. ::: :::option{name="adapters" dataType="Record"} A record of named adapters. Each adapter is created using one of the `drivers` helpers: `drivers.redis()`, `drivers.database()`, or `drivers.sync()`. You can configure multiple adapters and switch between them at runtime. ::: :::option{name="worker" dataType="WorkerConfig"} Configuration for the worker process. See [Worker configuration](#worker-configuration) for all available options. ::: :::option{name="locations" dataType="string[]"} An array of glob patterns that point to your job files. The queue system uses these patterns to auto-discover and register job classes. ```ts title="config/queue.ts" { locations: ['./app/jobs/**/*.{ts,js}'], } ``` ::: :::option{name="retry" dataType="RetryConfig"} Global retry configuration applied to all jobs unless overridden at the queue or job level. See [Retries and backoff](#retries-and-backoff) for details. ::: :::option{name="queues" dataType="Record"} Per-queue configuration allowing you to set different retry policies or default job options for specific queues. ```ts title="config/queue.ts" { queues: { emails: { retry: { maxRetries: 5, }, }, }, } ``` ::: :::option{name="defaultJobOptions" dataType="JobOptions"} Default options applied to all jobs. Individual jobs can override these in their `static options` property. ::: :::: ### Adapter configuration #### Redis The Redis adapter uses your `@adonisjs/redis` connection. It is the recommended choice for production due to its atomic operations and high throughput. ```ts title="config/queue.ts" import { defineConfig, drivers } from '@adonisjs/queue' export default defineConfig({ default: 'redis', adapters: { redis: drivers.redis({ // Uses the 'main' connection from config/redis.ts connectionName: 'main', }), }, // ... }) ``` You must have `@adonisjs/redis` installed and configured for this adapter to work. #### Database The Database adapter uses your `@adonisjs/lucid` connection with PostgreSQL, MySQL, or SQLite. This is a good choice when you want to avoid adding Redis to your infrastructure. ```ts title="config/queue.ts" import { defineConfig, drivers } from '@adonisjs/queue' export default defineConfig({ default: 'database', adapters: { database: drivers.database({ connectionName: 'primary', }), }, // ... }) ``` When selecting the database driver during installation, a migration is automatically created. If you need to create the tables manually, use `QueueSchemaService`: ```ts title="database/migrations/xxxx_create_queue_tables.ts" import { BaseSchema } from '@adonisjs/lucid/schema' import { QueueSchemaService } from '@boringnode/queue' export default class extends BaseSchema { async up() { const schemaService = new QueueSchemaService(this.db.getWriteClient()) await schemaService.createJobsTable() await schemaService.createSchedulesTable() } async down() { const schemaService = new QueueSchemaService(this.db.getWriteClient()) await schemaService.dropSchedulesTable() await schemaService.dropJobsTable() } } ``` You must have `@adonisjs/lucid` installed and configured for this adapter to work. #### Sync The Sync adapter executes jobs immediately in the same process, without a separate worker. This is useful for development and testing when you want to see job results right away. ```ts title="config/queue.ts" import { defineConfig, drivers } from '@adonisjs/queue' export default defineConfig({ default: 'sync', adapters: { sync: drivers.sync(), }, // ... }) ``` :::tip You can use the `QUEUE_DRIVER` environment variable to switch between adapters per environment. Use `redis` or `database` in production and `sync` in development. ::: ## Creating jobs A **job** is a class that encapsulates a unit of work to be executed in the background. Each job extends the `Job` base class with a typed payload. Generate a new job using the `make:job` Ace command: ```sh node ace make:job process_payment ``` This creates a job class at `app/jobs/process_payment.ts`: ```ts title="app/jobs/process_payment.ts" import { Job } from '@adonisjs/queue' import type { JobOptions } from '@adonisjs/queue/types' interface ProcessPaymentPayload { orderId: number amount: number currency: string } export default class ProcessPayment extends Job { static options: JobOptions = { queue: 'default', maxRetries: 3, } async execute() { const { orderId, amount, currency } = this.payload // Process the payment using your payment gateway console.log(`Processing payment of ${amount} ${currency} for order ${orderId}`) } async failed(error: Error) { console.error(`Payment processing failed for order ${this.payload.orderId}:`, error.message) } } ``` The `Job` class provides two methods to implement: - **`execute()`** (required): Contains the main job logic. Any error thrown here triggers the retry mechanism. - **`failed(error)`** (optional): Called when the job has permanently failed after all retries are exhausted. Use this for cleanup, logging, or sending notifications. ### Job options Configure default behavior for your job by setting the `static options` property. ::::options :::option{name="queue" dataType="string"} The queue to dispatch this job to. Defaults to `'default'`. ::: :::option{name="maxRetries" dataType="number"} Maximum number of retry attempts before the job fails permanently. Defaults to `3`. ::: :::option{name="priority" dataType="number"} Job priority from 1 to 10. Lower numbers are processed first. Defaults to `5`. ::: :::option{name="timeout" dataType="Duration"} Maximum execution time before the job is timed out. Accepts a number in milliseconds or a duration string like `'30s'` or `'5m'`. No timeout by default. ::: :::option{name="failOnTimeout" dataType="boolean"} Whether to mark the job as permanently failed on timeout. When `false`, the job is retried instead. Defaults to `true`. ::: :::option{name="retry" dataType="RetryConfig"} Retry configuration specific to this job, overriding global and queue-level settings. See [Retries and backoff](#retries-and-backoff). ::: :::option{name="removeOnComplete" dataType="JobRetention"} Controls whether completed jobs are kept in the history. Set to `true` to remove immediately (default), `false` to keep forever, or an object with `age` and/or `count` constraints. ```ts { removeOnComplete: { age: '7d', count: 1000 } } ``` ::: :::option{name="removeOnFail" dataType="JobRetention"} Controls whether failed jobs are kept in the history. Same format as `removeOnComplete`. ::: :::: ### Job context During execution, you can access metadata about the current job through `this.context`: ```ts title="app/jobs/process_payment.ts" export default class ProcessPayment extends Job { async execute() { // Access job metadata console.log(`Job ID: ${this.context.jobId}`) console.log(`Attempt: ${this.context.attempt}`) console.log(`Queue: ${this.context.queue}`) console.log(`Priority: ${this.context.priority}`) } } ``` ### Handling timeouts For long-running jobs, you can check `this.signal` to detect when a timeout has been reached and handle it gracefully: ```ts title="app/jobs/generate_report.ts" import { Job } from '@adonisjs/queue' import type { JobOptions } from '@adonisjs/queue/types' interface GenerateReportPayload { reportId: number rows: number[] } export default class GenerateReport extends Job { static options: JobOptions = { timeout: '5m', failOnTimeout: false, // Retry instead of failing permanently } async execute() { for (const rowId of this.payload.rows) { // Check if the job has timed out before processing each row if (this.signal?.aborted) { throw new Error('Job timed out during report generation') } await this.processRow(rowId) } } private async processRow(rowId: number) { // Process individual row } } ``` ### Dependency injection Jobs are instantiated through the AdonisJS IoC container, so you can use constructor injection to access services: ```ts title="app/jobs/process_payment.ts" import { inject } from '@adonisjs/core' import { Job } from '@adonisjs/queue' import type { JobOptions } from '@adonisjs/queue/types' import PaymentService from '#services/payment_service' interface ProcessPaymentPayload { orderId: number amount: number currency: string } @inject() export default class ProcessPayment extends Job { static options: JobOptions = { queue: 'payments', maxRetries: 3, } constructor(private paymentService: PaymentService) { super() } async execute() { await this.paymentService.charge( this.payload.orderId, this.payload.amount, this.payload.currency ) } } ``` ## Dispatching jobs Dispatch a job from anywhere in your application using the static `dispatch` method. The job payload is type-safe, matching the generic parameter defined on the job class. ```ts title="app/controllers/orders_controller.ts" import ProcessPayment from '#jobs/process_payment' export default class OrdersController { async store({ request, response }: HttpContext) { const order = await Order.create(request.body()) // Dispatch the job for background processing await ProcessPayment.dispatch({ orderId: order.id, amount: order.total, currency: 'USD', }) return response.created(order) } } ``` ### Dispatch options The `dispatch` method returns a fluent builder that lets you customize job behavior before sending it to the queue: ```ts title="app/controllers/orders_controller.ts" // Dispatch to a specific queue with high priority await ProcessPayment.dispatch({ orderId: order.id, amount: order.total, currency: 'USD', }) .toQueue('payments') .priority(1) ``` ::::options :::option{name=".toQueue(name)" dataType="string"} Send the job to a specific queue instead of the default one. ```ts await ProcessPayment.dispatch(payload).toQueue('payments') ``` ::: :::option{name=".priority(level)" dataType="number"} Set the job priority. Lower numbers are processed first (1 = highest priority, 10 = lowest). ```ts await ProcessPayment.dispatch(payload).priority(1) ``` ::: :::option{name=".in(delay)" dataType="Duration"} Delay job execution by a specified duration. Accepts milliseconds or a string like `'5m'`, `'1h'`, `'7d'`. ```ts // Send a reminder in 24 hours await SendReminder.dispatch(payload).in('24h') ``` ::: :::option{name=".group(groupId)" dataType="string"} Assign a group identifier for organizing related jobs. Useful for tracking batch operations. ```ts await GenerateReport.dispatch(payload).group('monthly-reports-2025') ``` ::: :::option{name=".with(adapter)" dataType="string"} Use a specific adapter for this job instead of the default one. ```ts await ProcessPayment.dispatch(payload).with('redis') ``` ::: :::: ### Batch dispatching When you need to dispatch many jobs of the same type, use `dispatchMany` for better performance. It uses batched operations (Redis pipelines or SQL batch inserts) under the hood. ```ts title="app/services/newsletter_service.ts" import SendNewsletter from '#jobs/send_newsletter' export default class NewsletterService { async sendToAllSubscribers(subscribers: { email: string }[]) { const payloads = subscribers.map((subscriber) => ({ email: subscriber.email, subject: 'Monthly Newsletter', })) const { jobIds } = await SendNewsletter.dispatchMany(payloads) .toQueue('emails') .group('newsletter-march-2025') console.log(`Dispatched ${jobIds.length} newsletter jobs`) } } ``` ## Retries and backoff When a job throws an error, the queue system automatically retries it according to the configured retry policy. You can configure retries at three levels, with more specific settings taking priority: **job > queue > global**. ### Backoff strategies A backoff strategy controls the delay between retry attempts. The package provides four built-in strategies: ```ts title="config/queue.ts" import { defineConfig, drivers } from '@adonisjs/queue' import { exponentialBackoff } from '@adonisjs/queue' export default defineConfig({ default: 'redis', adapters: { redis: drivers.redis({ connectionName: 'main' }), }, retry: { maxRetries: 3, backoff: exponentialBackoff(), }, // ... }) ``` **Exponential backoff** doubles the delay with each attempt: 1s, 2s, 4s, 8s, and so on. This is the recommended strategy for most use cases since it prevents overwhelming failing services. ```ts import { exponentialBackoff } from '@adonisjs/queue' // Default: 1s base, 5m max, 2x multiplier, jitter enabled exponentialBackoff() // Custom exponentialBackoff({ baseDelay: '500ms', maxDelay: '1m' }) ``` **Linear backoff** increases the delay by the base amount each attempt: 5s, 10s, 15s, 20s, and so on. ```ts import { linearBackoff } from '@adonisjs/queue' // Default: 5s base, 2m max linearBackoff() // Custom linearBackoff({ baseDelay: '10s', maxDelay: '5m' }) ``` **Fixed backoff** uses the same delay for every retry: 10s, 10s, 10s, and so on. ```ts import { fixedBackoff } from '@adonisjs/queue' // Default: 10s fixedBackoff() // Custom fixedBackoff('30s') ``` **Custom backoff** gives you full control over the strategy configuration: ```ts import { customBackoff } from '@adonisjs/queue' customBackoff({ strategy: 'exponential', baseDelay: '100ms', maxDelay: '30s', multiplier: 3, jitter: false, }) ``` :::tip Enable `jitter` on exponential and linear strategies to add randomness to retry delays. This prevents multiple failed jobs from retrying at the exact same time, which can overload downstream services (a pattern known as "thundering herd"). ::: ### Per-job retry You can override the retry configuration for specific jobs: ```ts title="app/jobs/process_payment.ts" import { Job } from '@adonisjs/queue' import { exponentialBackoff } from '@adonisjs/queue' import type { JobOptions } from '@adonisjs/queue/types' export default class ProcessPayment extends Job { static options: JobOptions = { maxRetries: 5, retry: { backoff: exponentialBackoff({ baseDelay: '2s', maxDelay: '10m' }), }, } async execute() { // Payment processing logic } } ``` ## Scheduled jobs Scheduled jobs run automatically at defined intervals or cron expressions. Define your schedules in the `start/scheduler.ts` preload file. ### Cron schedules Use a cron expression for precise scheduling: ```ts title="start/scheduler.ts" import CleanupExpiredSessions from '#jobs/cleanup_expired_sessions' import GenerateWeeklyReport from '#jobs/generate_weekly_report' // Run every night at midnight await CleanupExpiredSessions.schedule({ retentionDays: 30 }) .cron('0 0 * * *') .timezone('Europe/Paris') // Run every Monday at 9 AM await GenerateWeeklyReport.schedule({ type: 'weekly' }) .cron('0 9 * * MON') .timezone('America/New_York') ``` ### Interval schedules For simpler use cases, schedule jobs at a fixed interval: ```ts title="start/scheduler.ts" import SyncInventory from '#jobs/sync_inventory' // Run every 5 minutes await SyncInventory.schedule({ source: 'warehouse-api' }) .every('5m') ``` ### Schedule options The schedule builder supports the following options: ::::options :::option{name=".cron(expression)" dataType="string"} A cron expression defining when the job should run. Mutually exclusive with `.every()`. ::: :::option{name=".every(interval)" dataType="Duration"} A duration interval between runs. Mutually exclusive with `.cron()`. ::: :::option{name=".id(scheduleId)" dataType="string"} A custom identifier for the schedule. Defaults to the job class name. If a schedule with this ID already exists, it will be updated. ::: :::option{name=".timezone(tz)" dataType="string"} An IANA timezone for evaluating cron expressions. Defaults to `'UTC'`. ::: :::option{name=".from(date)" dataType="Date"} Start boundary. No jobs will be dispatched before this date. ::: :::option{name=".to(date)" dataType="Date"} End boundary. No jobs will be dispatched after this date. ::: :::option{name=".between(from, to)" dataType="Date, Date"} Shorthand for setting both `.from()` and `.to()`. ::: :::option{name=".limit(maxRuns)" dataType="number"} Maximum number of times the schedule will run. ::: :::: ### Managing schedules You can manage schedules programmatically using the `Schedule` class: ```ts title="app/controllers/admin_controller.ts" import { Schedule } from '@adonisjs/queue' export default class AdminController { async listSchedules() { // List all schedules const schedules = await Schedule.list() // List only active schedules const active = await Schedule.list({ status: 'active' }) return schedules } async pauseSchedule({ params }: HttpContext) { const schedule = await Schedule.find(params.id) if (schedule) { await schedule.pause() } } async resumeSchedule({ params }: HttpContext) { const schedule = await Schedule.find(params.id) if (schedule) { await schedule.resume() } } async triggerSchedule({ params }: HttpContext) { const schedule = await Schedule.find(params.id) if (schedule) { // Immediately dispatch the scheduled job await schedule.trigger() } } async deleteSchedule({ params }: HttpContext) { const schedule = await Schedule.find(params.id) if (schedule) { await schedule.delete() } } } ``` ### Scheduler Ace commands The package provides Ace commands for managing schedules from the terminal: ```sh # List all scheduled jobs node ace queue:scheduler:list # List only active schedules node ace queue:scheduler:list --status=active # Remove a specific schedule node ace queue:scheduler:remove # Remove all schedules node ace queue:scheduler:clear ``` ## Running the worker Jobs are not processed until you start a worker. The worker is a long-running process that polls the queue for available jobs and executes them. Start the worker using the `queue:work` Ace command: ```sh node ace queue:work ``` ### Worker options ```sh # Process specific queues node ace queue:work --queue=payments,emails # Set concurrency (number of jobs processed simultaneously) node ace queue:work --concurrency=10 ``` :::warning You must start the worker as a separate process alongside your web server. Jobs dispatched from your application will not be processed until a worker is running. In production, use a process manager like [PM2](https://pm2.keymetrics.io/) or a container orchestrator to keep the worker running and restart it on failure. ::: ### Worker configuration Configure worker behavior in your `config/queue.ts` file: ```ts title="config/queue.ts" export default defineConfig({ // ... worker: { concurrency: 5, idleDelay: '2s', stalledThreshold: '30s', stalledInterval: '30s', maxStalledCount: 1, gracefulShutdown: true, }, }) ``` ::::options :::option{name="concurrency" dataType="number"} Maximum number of jobs processed simultaneously. Defaults to `1`. ::: :::option{name="idleDelay" dataType="Duration"} How long the worker waits before polling again when no jobs are available. Defaults to `'2s'`. ::: :::option{name="timeout" dataType="Duration"} Global maximum execution time for any job. Can be overridden per job via `JobOptions.timeout`. No timeout by default. ::: :::option{name="stalledThreshold" dataType="Duration"} How long a job can run before it is considered stalled (the worker may have crashed). Defaults to `'30s'`. ::: :::option{name="stalledInterval" dataType="Duration"} How often the worker checks for stalled jobs. Defaults to `'30s'`. ::: :::option{name="maxStalledCount" dataType="number"} Maximum number of times a stalled job can be recovered before it is permanently failed. Defaults to `1`. ::: :::option{name="gracefulShutdown" dataType="boolean"} When `true`, the worker finishes running jobs before stopping on SIGINT/SIGTERM signals. Defaults to `true`. ::: :::: ## Testing The package provides a fake adapter that records dispatched jobs in memory and exposes assertion helpers. This lets you verify that your application dispatches the right jobs without actually processing them. ### Faking the queue Use `QueueManager.fake()` to replace all adapters with the fake adapter, and `QueueManager.restore()` to revert back: ```ts title="tests/functional/orders.spec.ts" import { test } from '@japa/runner' import { QueueManager } from '@adonisjs/queue' import ProcessPayment from '#jobs/process_payment' test.group('Orders', (group) => { group.each.teardown(() => { QueueManager.restore() }) test('dispatches a payment job when creating an order', async ({ client }) => { const fake = QueueManager.fake() const response = await client.post('/orders').json({ product_id: 1, quantity: 2, }) response.assertStatus(201) // Assert the job was dispatched fake.assertPushed(ProcessPayment) // Assert with payload matching fake.assertPushed(ProcessPayment, { payload: { orderId: 1, amount: 100, currency: 'USD' }, }) }) test('does not dispatch a payment job for free orders', async ({ client }) => { const fake = QueueManager.fake() await client.post('/orders').json({ product_id: 1, quantity: 0, }) fake.assertNotPushed(ProcessPayment) }) }) ``` ### Assertion methods The fake adapter provides the following assertion methods: ::::options :::option{name="assertPushed(job, query?)" dataType="void"} Assert that a job was dispatched. Optionally filter by queue, payload, or delay. ```ts fake.assertPushed(ProcessPayment) fake.assertPushed(ProcessPayment, { queue: 'payments' }) fake.assertPushed(ProcessPayment, { payload: { orderId: 1 }, }) ``` ::: :::option{name="assertNotPushed(job, query?)" dataType="void"} Assert that a job was not dispatched. ```ts fake.assertNotPushed(SendEmail) ``` ::: :::option{name="assertPushedCount(count, options?)" dataType="void"} Assert the total number of dispatched jobs, optionally filtered by queue. ```ts fake.assertPushedCount(3) fake.assertPushedCount(2, { queue: 'emails' }) ``` ::: :::option{name="assertNothingPushed()" dataType="void"} Assert that no jobs were dispatched at all. ```ts fake.assertNothingPushed() ``` ::: :::: ### Advanced matching You can use functions for more complex payload matching: ```ts title="tests/functional/newsletter.spec.ts" import { test } from '@japa/runner' import { QueueManager } from '@adonisjs/queue' import SendNewsletter from '#jobs/send_newsletter' test('dispatches newsletter jobs for all subscribers', async ({ client }) => { const fake = QueueManager.fake() await client.post('/newsletters/send') // Match with a function fake.assertPushed(SendNewsletter, { payload: (payload) => payload.email.endsWith('@example.com'), }) // Check delay fake.assertPushed(SendNewsletter, { delay: (delay) => delay !== undefined && delay > 0, }) }) ``` --- # Transmit This guide covers real-time server-to-client communication with Transmit in AdonisJS. You will learn how to: - Install and configure Transmit for Server-Sent Events - Register routes and broadcast events to connected clients - Define channels and authorize access to private channels - Set up the client library to receive events in real time - Synchronize events across multiple server instances using transports - Listen to lifecycle hooks for monitoring connections ## Overview Transmit is a native Server-Sent Events (SSE) module for AdonisJS. It provides a unidirectional communication channel from server to client, allowing you to push real-time updates without the overhead of WebSockets. Because SSE uses standard HTTP, it works through firewalls and proxies that might block WebSocket connections. Transmit works as a publish/subscribe system built around channels. The server broadcasts messages to named channels, and clients subscribe to the channels they care about. You can protect channels with authorization callbacks to control who receives updates, making it suitable for both public broadcasts and private, user-specific notifications. For client-to-server communication, you continue to use standard HTTP requests. Transmit only handles the server-to-client push. ## Installation Install and configure the server-side package using the following command: ```sh node ace add @adonisjs/transmit ``` :::disclosure{title="See steps performed by the add command"} 1. Installs the `@adonisjs/transmit` package using the detected package manager. 2. Registers the following service provider inside the `adonisrc.ts` file. ```ts title="adonisrc.ts" { providers: [ // ...other providers () => import('@adonisjs/transmit/transmit_provider') ] } ``` 3. Creates the `config/transmit.ts` file. ::: Also install the client library in your frontend application: ```sh npm install @adonisjs/transmit-client ``` ## Configuration The configuration file lives at `config/transmit.ts`. It controls keep-alive behavior and multi-instance synchronization. See also: [Config stub](https://github.com/adonisjs/transmit/blob/-/stubs/config/transmit.stub) ```ts title="config/transmit.ts" import { defineConfig } from '@adonisjs/transmit' export default defineConfig({ pingInterval: false, transport: null, }) ``` ::::options :::option{name="pingInterval" dataType="Duration | false"} Controls how often ping messages are sent to keep SSE connections alive. Accepts a number in milliseconds, a duration string like `'30s'` or `'1m'`, or `false` to disable pings. ```ts title="config/transmit.ts" import { defineConfig } from '@adonisjs/transmit' export default defineConfig({ pingInterval: '30s', transport: null, }) ``` ::: :::option{name="transport" dataType="object | null"} Configures the transport layer for synchronizing events across multiple server instances. Set to `null` for single-instance deployments. See [Multi-instance synchronization](#multi-instance-synchronization) for configuration details. ::: :::: ## Registering routes Transmit requires three HTTP routes to handle client connections, subscriptions, and unsubscriptions. Register them in your routes file using the `registerRoutes` method. ```ts title="start/routes.ts" import transmit from '@adonisjs/transmit/services/main' transmit.registerRoutes() ``` This registers the following routes: | Route | Method | Purpose | |-------|--------|---------| | `__transmit/events` | GET | Establishes the SSE connection | | `__transmit/subscribe` | POST | Subscribes the client to a channel | | `__transmit/unsubscribe` | POST | Unsubscribes the client from a channel | ### Applying middleware to routes The `registerRoutes` method accepts an optional callback to modify each registered route. This is useful for applying middleware, such as requiring authentication for the SSE connection. ```ts title="start/routes.ts" import transmit from '@adonisjs/transmit/services/main' import { middleware } from '#start/kernel' transmit.registerRoutes((route) => { route.middleware(middleware.auth()) }) ``` You can apply middleware conditionally based on the route pattern. ```ts title="start/routes.ts" import transmit from '@adonisjs/transmit/services/main' import { middleware } from '#start/kernel' transmit.registerRoutes((route) => { // Only require authentication for the SSE connection if (route.getPattern() === '__transmit/events') { route.middleware(middleware.auth()) } }) ``` ## Broadcasting events Import the transmit service and call the `broadcast` method to send data to all subscribers of a channel. ```ts title="app/controllers/posts_controller.ts" import transmit from '@adonisjs/transmit/services/main' import type { HttpContext } from '@adonisjs/core/http' export default class PostsController { async store({ request }: HttpContext) { const post = await Post.create(request.all()) // Broadcast the new post to all subscribers transmit.broadcast('posts', { id: post.id, title: post.title }) return post } } ``` ### Excluding specific clients Use `broadcastExcept` to send a message to all subscribers except one or more specific clients. This is useful when the sender should not receive their own message. ```ts title="app/controllers/messages_controller.ts" import transmit from '@adonisjs/transmit/services/main' import type { HttpContext } from '@adonisjs/core/http' export default class MessagesController { async store({ request }: HttpContext) { const { uid, content } = request.all() // Send to everyone in the chat except the sender transmit.broadcastExcept('chats/1/messages', { content }, uid) } } ``` The third argument accepts a single UID string or an array of UIDs to exclude. ## Channels Channel names are case-sensitive strings that support alphanumeric characters and forward slashes. Use forward slashes to create hierarchical structures that match your application's resources. ```ts // Public channel for global notifications transmit.broadcast('notifications', { message: 'System update' }) // Resource-specific channel transmit.broadcast('chats/1/messages', { content: 'Hello!' }) // User-specific channel transmit.broadcast('users/42', { type: 'profile_updated' }) ``` ### Authorizing channels By default, any client can subscribe to any channel. Use the `authorize` method to restrict access to sensitive channels. Create a `start/transmit.ts` preload file to define your authorization rules. ```sh node ace make:preload transmit ``` Authorization callbacks receive the current `HttpContext` and the extracted channel parameters. Return `true` to allow access or `false` to deny it. ```ts title="start/transmit.ts" import transmit from '@adonisjs/transmit/services/main' // Only allow users to subscribe to their own channel transmit.authorize<{ id: string }>('users/:id', (ctx, { id }) => { return ctx.auth.user?.id === +id }) ``` Channel patterns use the same parameter syntax as AdonisJS routes. Parameters are extracted from the channel name at subscription time and passed to the authorization callback. ```ts title="start/transmit.ts" import transmit from '@adonisjs/transmit/services/main' import Chat from '#models/chat' transmit.authorize<{ chatId: string }>( 'chats/:chatId/messages', async (ctx, { chatId }) => { const chat = await Chat.findOrFail(+chatId) return ctx.bouncer.allows('accessChat', chat) } ) ``` Authorization callbacks support both synchronous and asynchronous logic. If the callback throws an error, access is denied. :::tip Channels without an `authorize` callback are public. Any client can subscribe to them. Only register authorization for channels that require access control. ::: ## Client-side setup Create a new `Transmit` instance with the URL of your AdonisJS server. The client automatically establishes an SSE connection when instantiated. ```ts title="resources/js/app.ts" import { Transmit } from '@adonisjs/transmit-client' const transmit = new Transmit({ baseUrl: window.location.origin, }) ``` ### Subscribing to channels Use the `subscription` method to create a subscription, then call `create` to activate it. Register message handlers with `onMessage`. ```ts title="resources/js/chat.ts" import { Transmit } from '@adonisjs/transmit-client' const transmit = new Transmit({ baseUrl: window.location.origin, }) const subscription = transmit.subscription('chats/1/messages') await subscription.create() subscription.onMessage((data) => { console.log('New message:', data) }) ``` You can register multiple message handlers on a single subscription. Each handler receives the parsed payload from the server. ```ts title="resources/js/chat.ts" // Register multiple handlers subscription.onMessage((data) => { appendMessageToUI(data) }) subscription.onMessage((data) => { playNotificationSound() }) // Register a handler that runs only once subscription.onMessageOnce((data) => { console.log('First message received:', data) }) ``` ### Removing a message handler The `onMessage` method returns an unsubscribe function to stop a specific handler from receiving messages. ```ts title="resources/js/chat.ts" const unsubscribe = subscription.onMessage((data) => { console.log(data) }) // Later, stop this specific handler unsubscribe() ``` ### Deleting a subscription Call `delete` to unsubscribe from a channel entirely. ```ts title="resources/js/chat.ts" await subscription.delete() ``` ### Listening to connection status The client tracks its connection status and exposes events you can listen to. ```ts title="resources/js/app.ts" transmit.on('connected', () => { console.log('SSE connection established') }) transmit.on('disconnected', () => { console.log('SSE connection lost') }) transmit.on('reconnecting', () => { console.log('Attempting to reconnect...') }) ``` The available status events are `initializing`, `connected`, `disconnected`, and `reconnecting`. ### Client configuration options ::::options :::option{name="baseUrl" dataType="string"} The URL of your AdonisJS server, including the protocol. This is the only required option. ```ts const transmit = new Transmit({ baseUrl: 'https://my-app.com', }) ``` ::: :::option{name="maxReconnectAttempts" dataType="number"} Maximum number of reconnection attempts when the connection drops. Defaults to `5`. ```ts const transmit = new Transmit({ baseUrl: window.location.origin, maxReconnectAttempts: 10, }) ``` ::: :::option{name="uidGenerator" dataType="() => string"} Custom function to generate the client's unique identifier. Defaults to `crypto.randomUUID()`. ```ts import { nanoid } from 'nanoid' const transmit = new Transmit({ baseUrl: window.location.origin, uidGenerator: () => nanoid(), }) ``` ::: :::option{name="beforeSubscribe" dataType="(request: Request) => void"} Hook called before each subscribe request. Use it to modify the request, such as adding custom headers. ```ts const transmit = new Transmit({ baseUrl: window.location.origin, beforeSubscribe: (request) => { request.headers.set('X-Custom-Header', 'value') }, }) ``` ::: :::option{name="beforeUnsubscribe" dataType="(request: Request) => void"} Hook called before each unsubscribe request. Works the same as `beforeSubscribe`. ::: :::option{name="onReconnectAttempt" dataType="(attempt: number) => void"} Callback invoked on each reconnection attempt. Receives the current attempt number. ::: :::option{name="onReconnectFailed" dataType="() => void"} Callback invoked when the maximum number of reconnection attempts is reached and the client stops trying. ::: :::option{name="onSubscribeFailed" dataType="(response: Response) => void"} Callback invoked when a subscribe request fails. Receives the `Response` object from the failed request. ::: :::option{name="onSubscription" dataType="(channel: string) => void"} Callback invoked when a subscription is successfully created. ::: :::option{name="onUnsubscription" dataType="(channel: string) => void"} Callback invoked when a subscription is successfully deleted. ::: :::option{name="eventSourceFactory" dataType="(url: string | URL, options: { withCredentials: boolean }) => EventSource"} Custom factory for creating the `EventSource` instance. Useful for environments where the native `EventSource` is not available. ::: :::option{name="eventTargetFactory" dataType="() => EventTarget | null"} Custom factory for creating the `EventTarget` used for status change events. Return `null` to disable status events. ::: :::option{name="httpClientFactory" dataType="(baseUrl: string, uid: string) => HttpClient"} Custom factory for creating the HTTP client used for subscribe and unsubscribe requests. ::: :::: ## Lifecycle hooks The server-side transmit instance emits lifecycle events you can listen to for monitoring and debugging. ```ts title="start/transmit.ts" import transmit from '@adonisjs/transmit/services/main' transmit.on('connect', ({ uid }) => { console.log(`Client ${uid} connected`) }) transmit.on('disconnect', ({ uid }) => { console.log(`Client ${uid} disconnected`) }) transmit.on('broadcast', ({ channel, payload }) => { console.log(`Broadcast on ${channel}:`, payload) }) transmit.on('subscribe', ({ uid, channel }) => { console.log(`Client ${uid} subscribed to ${channel}`) }) transmit.on('unsubscribe', ({ uid, channel }) => { console.log(`Client ${uid} unsubscribed from ${channel}`) }) ``` The `connect`, `disconnect`, `subscribe`, and `unsubscribe` event callbacks also receive a `context` property containing the `HttpContext` of the request. ## Getting channel subscribers Use the `getSubscribersFor` method to retrieve the UIDs of all clients currently subscribed to a channel. ```ts import transmit from '@adonisjs/transmit/services/main' const subscribers = transmit.getSubscribersFor('chats/1/messages') console.log(`${subscribers.length} clients connected to this chat`) ``` ## Multi-instance synchronization When running multiple server instances behind a load balancer, events broadcast on one instance will not reach clients connected to other instances. Transmit solves this with a transport layer that synchronizes events across all instances using a message bus. ### Redis transport Install the `ioredis` package and configure the Redis transport. ```sh npm install ioredis ``` ```ts title="config/transmit.ts" import env from '#start/env' import { defineConfig } from '@adonisjs/transmit' import { redis } from '@adonisjs/transmit/transports' export default defineConfig({ pingInterval: '30s', transport: { driver: redis({ host: env.get('REDIS_HOST'), port: env.get('REDIS_PORT'), password: env.get('REDIS_PASSWORD'), keyPrefix: 'transmit', }), }, }) ``` ### MQTT transport ```ts title="config/transmit.ts" import env from '#start/env' import { defineConfig } from '@adonisjs/transmit' import { mqtt } from '@adonisjs/transmit/transports' export default defineConfig({ pingInterval: '30s', transport: { driver: mqtt({ url: env.get('MQTT_URL'), }), }, }) ``` The transport broadcasts events to all connected instances through the configured message bus. The default broadcast channel is `'transmit::broadcast'`. You can customize it if needed. ```ts title="config/transmit.ts" export default defineConfig({ transport: { driver: redis({ /* ... */ }), channel: 'my-app::transmit', }, }) ``` ## Production considerations ### Disable compression for SSE Server-Sent Events require that the response stream is not compressed. If your reverse proxy applies GZip compression, you must exclude the `text/event-stream` content type. Compressed SSE streams cause connection instability and message buffering. :::warning You must disable compression for the `text/event-stream` content type in your reverse proxy. Failing to do so will cause SSE connections to break or buffer messages indefinitely. For Traefik: ```yaml traefik.http.middlewares.gzip.compress.excludedcontenttypes=text/event-stream ``` For Nginx: ```nginx # Do not include text/event-stream in gzip_types gzip_types text/plain application/json application/javascript text/css; ``` ::: --- # OpenTelemetry This guide covers OpenTelemetry integration in AdonisJS applications. You will learn how to: - Install and configure the `@adonisjs/otel` package - Understand traces, spans, and attributes - Use automatic instrumentation for HTTP, database, and Redis - Create custom spans with helpers and decorators - Propagate trace context across services - Test your setup locally with Jaeger ## Overview OpenTelemetry is an open standard for collecting telemetry data from your applications: traces, metrics, and logs. The `@adonisjs/otel` package provides a seamless integration between AdonisJS and OpenTelemetry, giving you distributed tracing and automatic instrumentation with sensible defaults. Observability is essential for understanding what happens inside your application, especially in production. When a user reports that "the checkout page is slow," tracing lets you see exactly where time is spent. Was it the database query? An external API call? A slow service? Without tracing, you're left guessing. :::media ![alt text](./tracing.png) ::: This package handles the complexity of OpenTelemetry setup for you. Run a single command, and your application automatically traces HTTP requests, database queries, Redis operations, and more. ## OpenTelemetry concepts Before diving into the implementation, you should understand a few core OpenTelemetry concepts. For a comprehensive introduction, see the [official OpenTelemetry documentation](https://opentelemetry.io/docs/concepts/observability-primer/). A **trace** represents the complete journey of a request through your system. When a user hits your API, the trace captures everything that happens: the HTTP request, database queries, cache lookups, calls to external services, and the response. A **span** is a single unit of work within a trace. Each database query, HTTP request, or function call can be a span. Spans have a start time, duration, name, and attributes (key-value metadata). Spans are nested hierarchically: a parent span for the HTTP request contains child spans for each database query made during that request. **Attributes** are key-value pairs attached to spans that provide context. For example, an HTTP span might have attributes like `http.method: GET`, `http.route: /users/:id`, and `http.status_code: 200`. ## Installation Install and configure the package using the following command. ```sh node ace add @adonisjs/otel ``` :::disclosure{title="See steps performed by the add command"} 1. Installs the `@adonisjs/otel` package using the detected package manager. 2. Registers the following service provider inside the `adonisrc.ts` file. ```ts { providers: [ // ...other providers () => import('@adonisjs/otel/otel_provider') ] } ``` 3. Registers the following middleware inside the `start/kernel.ts` file. ```ts router.use([ () => import('@adonisjs/otel/otel_middleware') ]) ``` 4. Creates the `config/otel.ts` file. 5. Creates the `bin/otel.ts` file with OpenTelemetry initialization. 6. Adds the import statement at the top of `bin/server.ts` file. 7. Defines the following environment variables and their validation rules. ```dotenv OTEL_EXPORTER_OTLP_ENDPOINT= OTEL_EXPORTER_OTLP_HEADERS= ``` ::: That's it. Your application now has automatic tracing for HTTP requests, database queries, and more. ## Configuration The configuration file is located at `config/otel.ts`. ```ts title="config/otel.ts" import { defineConfig } from '@adonisjs/otel' import env from '#start/env' export default defineConfig({ serviceName: env.get('APP_NAME'), serviceVersion: env.get('APP_VERSION'), environment: env.get('APP_ENV'), }) ``` ### Service identification The package resolves service metadata from multiple sources. ::::options :::option{name="serviceName" dataType="string"} The name of your service. This value is resolved from `OTEL_SERVICE_NAME` or `APP_NAME` environment variables. ```ts export default defineConfig({ serviceName: 'my-api' }) ``` ::: :::option{name="serviceVersion" dataType="string"} The version of your service. This value is resolved from the `APP_VERSION` environment variable and defaults to `0.0.0`. ```ts export default defineConfig({ serviceVersion: '1.2.3' }) ``` ::: :::option{name="environment" dataType="string"} The environment where your service is running. This value is resolved from the `APP_ENV` environment variable and defaults to `development`. ```ts export default defineConfig({ environment: 'production' }) ``` ::: :::: ### Exporters By default, the package exports traces using OTLP over gRPC to `localhost:4317`. This is the standard OpenTelemetry Collector endpoint. If you're running an OpenTelemetry Collector locally or in your infrastructure, traces will be sent there automatically. You can configure the exporter endpoint using environment variables without changing any code. ```dotenv # title: .env OTEL_EXPORTER_OTLP_ENDPOINT=https://otel-collector.example.com:4317 ``` For authentication or custom headers: ```dotenv # title: .env OTEL_EXPORTER_OTLP_HEADERS=x-api-key=your-api-key ``` See the [OpenTelemetry environment variable specification](https://opentelemetry.io/docs/specs/otel/configuration/sdk-environment-variables/) for all available options, and check [Advanced configuration](#advanced-configuration) for even more customization. ### Multiple destinations (fan-out) When you need to export telemetry to multiple backends at once, use the `destinations` option. The package provides a generic OTLP destination helper via `destinations.otlp()`. Each destination can receive all signals (`traces`, `metrics`, `logs`) or only a subset. ```ts title="config/otel.ts" import { defineConfig, destinations } from '@adonisjs/otel' export default defineConfig({ serviceName: 'my-app', destinations: { grafana: destinations.otlp({ endpoint: 'https://grafana-otlp.example.com', headers: { Authorization: `Basic ${process.env.GRAFANA_BASIC_AUTH}`, }, signals: 'all', }), honeycomb: destinations.otlp({ endpoint: 'https://api.honeycomb.io', headers: { 'x-honeycomb-team': process.env.HONEYCOMB_API_KEY!, 'x-honeycomb-dataset': process.env.HONEYCOMB_DATASET!, }, signals: 'all', }), }, }) ``` When you set `endpoint`, the package automatically derives per-signal endpoints by appending: - `/v1/traces` - `/v1/metrics` - `/v1/logs` You can also provide explicit endpoints per signal: ```ts title="config/otel.ts" import { defineConfig, destinations } from '@adonisjs/otel' export default defineConfig({ serviceName: 'my-app', destinations: { custom: destinations.otlp({ signals: ['traces', 'logs'], endpoints: { traces: 'https://collector-a.example.com/v1/traces', logs: 'https://collector-b.example.com/v1/logs', }, }), }, }) ``` :::note `destinations` is optional. If you do not define it, the package keeps the default OpenTelemetry behavior and environment variable configuration (`OTEL_EXPORTER_OTLP_*`, `OTEL_TRACES_EXPORTER`, `OTEL_METRICS_EXPORTER`, `OTEL_LOGS_EXPORTER`). ::: ### Debug mode Enable debug mode to print spans to the console during development. ```ts title="config/otel.ts" import { defineConfig } from '@adonisjs/otel' export default defineConfig({ serviceName: 'my-app', debug: true, }) ``` This adds a `ConsoleSpanExporter` that outputs spans to your terminal, helping you visualize traces without setting up a collector. ### Enabling and disabling OpenTelemetry is automatically disabled when `NODE_ENV === 'test'` to avoid noise during tests. You can override this behavior. ```ts title="config/otel.ts" import { defineConfig } from '@adonisjs/otel' export default defineConfig({ serviceName: 'my-app', /** * Force enable in tests */ enabled: true, /** * Or force disable in any environment */ enabled: false, }) ``` ### Sampling In high-traffic production environments, tracing every single request generates enormous amounts of data. Sampling controls what percentage of traces are collected. ```ts title="config/otel.ts" import { defineConfig } from '@adonisjs/otel' export default defineConfig({ serviceName: 'my-app', /** * Sample 10% of traces (recommended for high-traffic production) */ samplingRatio: 0.1, /** * Sample 100% of traces (default, good for development) */ samplingRatio: 1.0, }) ``` The sampler uses parent-based sampling, meaning child spans inherit the sampling decision from their parent. This ensures you always get complete traces rather than fragments. See also: [OpenTelemetry Sampling documentation](https://opentelemetry.io/docs/concepts/sampling/) :::note If you provide a custom `sampler` option, `samplingRatio` is ignored. ::: ### Customizing instrumentations The package automatically instruments common libraries without any code changes. Out of the box, you get tracing for HTTP requests (incoming and outgoing), Lucid database queries (via Knex), and Redis operations. To reduce noise, the following endpoints are excluded from tracing by default. - `/health`, `/healthz`, `/ready`, `/readiness` - `/metrics`, `/internal/metrics`, `/internal/healthz` - `/favicon.ico` You can configure individual instrumentations or add custom ignored URLs. ```ts title="config/otel.ts" import { defineConfig } from '@adonisjs/otel' export default defineConfig({ serviceName: 'my-app', instrumentations: { /** * Add custom ignored URLs (merged with defaults) */ '@opentelemetry/instrumentation-http': { ignoredUrls: ['/internal/*', '/api/ping'], mergeIgnoredUrls: true, }, /** * Disable a specific instrumentation */ '@opentelemetry/instrumentation-pg': { enabled: false }, }, }) ``` ## Testing locally with Jaeger Jaeger is a popular open-source distributed tracing platform that implements the OpenTelemetry standard. It provides a web UI for visualizing traces, making it perfect for local development. ::::steps :::step{title="Start Jaeger using Docker"} Run Jaeger in a Docker container. The all-in-one image includes the collector, query service, and UI. ```sh docker run -d --name jaeger \ -e COLLECTOR_OTLP_ENABLED=true \ -p 16686:16686 \ -p 4317:4317 \ -p 4318:4318 \ jaegertracing/all-in-one:latest ``` This command exposes: - Port `16686` for the Jaeger UI - Port `4317` for OTLP gRPC (the default endpoint) - Port `4318` for OTLP HTTP ::: :::step{title="Start your application"} Your AdonisJS application is already configured to send traces to `localhost:4317` by default, so no additional configuration is needed. ```sh node ace serve --hmr ``` ::: :::step{title="Generate some traces"} Make requests to your application to generate traces. You can use curl, your browser, or any HTTP client. ```sh curl http://localhost:3333/users curl http://localhost:3333/posts/1 ``` ::: :::step{title="View traces in Jaeger"} Open the Jaeger UI at http://localhost:16686. You should see your service listed in the dropdown. Select your service and click "Find Traces" to see all captured traces. Click on any trace to see the complete timeline of operations: the HTTP request, database queries, and any custom spans you've created. ::: :::: :::tip Keep Jaeger running during development. It accumulates traces over time, making it easy to compare slow and fast requests or spot patterns in your application's behavior. ::: ## Creating custom spans While automatic instrumentation covers most common operations, you'll often want to trace custom business logic. The package provides helpers and decorators for this purpose. ### Using the record helper The `record` helper creates a span around a code section: ```ts title="app/services/order_service.ts" import { record } from '@adonisjs/otel/helpers' export default class OrderService { async processOrder(orderId: string) { /** * Wrap synchronous or asynchronous code in a span */ // [!code highlight:6] const result = await record('order.process', async () => { const order = await Order.findOrFail(orderId) await this.validateInventory(order) await this.chargePayment(order) return order }) return result } async validateInventory(order: Order) { /** * Access the span to add custom attributes */ // [!code highlight:8] await record('order.validate_inventory', async (span) => { span.setAttributes({ 'order.id': order.id, 'order.items_count': order.items.length, }) // Validation logic... }) } } ``` ### Using decorators For class methods, decorators provide a cleaner syntax. ```ts title="app/services/user_service.ts" import { span, spanAll } from '@adonisjs/otel/decorators' export default class UserService { /** * Creates a span named "UserService.findById" */ // [!code highlight] @span() async findById(id: string) { return User.find(id) } /** * Custom span name and attributes */ // [!code highlight] @span({ name: 'user.create', attributes: { operation: 'create' } }) async create(data: UserData) { return User.create(data) } } ``` To automatically trace all methods of a class, use the `@spanAll` decorator. ```ts title="app/services/payment_service.ts" import { spanAll } from '@adonisjs/otel/decorators' /** * All methods get spans. */ // [!code highlight] @spanAll() export default class PaymentService { async charge(amount: number) { // ... } async refund(transactionId: string) { // ... } } /** * Custom prefix: "payment.charge", "payment.refund", etc. */ @spanAll({ prefix: 'payment' }) export default class PaymentService { // ... } ``` ### Setting attributes on the current span Use `setAttributes` to add metadata to the currently active span without creating a new one. ```ts title="app/controllers/orders_controller.ts" import { setAttributes } from '@adonisjs/otel/helpers' export default class OrdersController { async store({ request }: HttpContext) { const data = request.all() /** * Add business context to the HTTP span */ setAttributes({ 'order.type': data.type, 'order.total': data.total, 'customer.tier': data.customerTier, }) // Process order... } } ``` ### Recording events Events are time-stamped annotations within a span. Use them to mark significant moments. ```ts title="app/services/checkout_service.ts" import { recordEvent } from '@adonisjs/otel/helpers' export default class CheckoutService { async process(cart: Cart) { // [!code highlight] recordEvent('checkout.started') await this.validateCart(cart) // [!code highlight] recordEvent('checkout.cart_validated') const payment = await this.processPayment(cart) // [!code highlight:4] recordEvent('checkout.payment_processed', { 'payment.method': payment.method, 'payment.amount': payment.amount, }) await this.fulfillOrder(cart) // [!code highlight] recordEvent('checkout.completed') } } ``` ## Context propagation When your application calls other services or processes background jobs, you need to propagate the trace context so all operations appear in the same trace. ### Propagating to HTTP calls Inject trace context into outgoing HTTP request headers. ```ts title="app/services/external_api_service.ts" import { injectTraceContext } from '@adonisjs/otel/helpers' export default class ExternalApiService { async fetchUserData(userId: string) { const headers: Record = { 'Content-Type': 'application/json', } /** * Adds traceparent and tracestate headers. * Mutates the original object */ // [!code highlight] injectTraceContext(headers) const response = await fetch(`https://api.example.com/users/${userId}`, { headers, }) return response.json() } } ``` ### Propagating to queue jobs When dispatching background jobs, include the trace context. ```ts title="app/controllers/orders_controller.ts" import { injectTraceContext } from '@adonisjs/otel/helpers' export default class OrdersController { async store({ request }: HttpContext) { const order = await Order.create(request.all()) /** * Include trace context in job metadata */ const traceHeaders: Record = {} // [!code highlight] injectTraceContext(traceHeaders) await queue.dispatch('process-order', { orderId: order.id, traceContext: traceHeaders, }) return order } } ``` In your queue worker, extract the context and continue the trace. ```ts title="app/jobs/process_order_job.ts" import { extractTraceContext, record } from '@adonisjs/otel/helpers' import { context } from '@adonisjs/otel' export default class ProcessOrderJob { async handle(payload: { orderId: string; traceContext: Record }) { /** * Extract the trace context from the job payload */ // [!code highlight] const extractedContext = extractTraceContext(payload.traceContext) /** * Run the job within the extracted context */ // [!code highlight] await context.with(extractedContext, async () => { await record('job.process_order', async () => { /** * This span will be a child of the original HTTP request span */ const order = await Order.findOrFail(payload.orderId) await this.fulfillOrder(order) }) }) } } ``` ## User context When `@adonisjs/auth` is installed, the middleware automatically sets user attributes on spans if a user is authenticated. This includes `user.id`, `user.email` (if available), and `user.roles` (if available). You can customize this behavior or add additional user attributes. ```ts title="config/otel.ts" import { defineConfig } from '@adonisjs/otel' export default defineConfig({ serviceName: 'my-app', /** * Disable automatic user context */ userContext: false, /** * Or customize with a resolver */ userContext: { resolver: async (ctx) => { if (!ctx.auth.user) { return null } return { id: ctx.auth.user.id, tenantId: ctx.auth.user.tenantId, plan: ctx.auth.user.plan, } }, }, }) ``` You can also manually set user context anywhere in your code. ```ts title="app/middleware/auth_middleware.ts" import { setUser } from '@adonisjs/otel/helpers' export default class AuthMiddleware { async handle({ auth }: HttpContext, next: NextFn) { await auth.authenticate() setUser({ id: auth.user!.id, email: auth.user!.email, role: auth.user!.role, }) return next() } } ``` ## Logging integration The package automatically injects trace context into Pino logs, adding `trace_id` and `span_id` to each log entry. This lets you correlate logs with traces in your observability platform. When using `pino-pretty` for development, you can hide these fields for cleaner output. ```ts title="config/logger.ts" import app from '@adonisjs/core/services/app' import { otelLoggingPreset } from '@adonisjs/otel/helpers' import { defineConfig, targets } from '@adonisjs/core/logger' export default defineConfig({ default: 'app', loggers: { app: { transport: { targets: targets() .pushIf(!app.inProduction, targets.pretty({ ...otelLoggingPreset() })) .toArray(), }, }, }, }) ``` To keep specific fields visible. ```ts otelLoggingPreset({ keep: ['trace_id', 'span_id'] }) ``` ## Advanced configuration The `defineConfig` function accepts all options from the [OpenTelemetry Node SDK](https://opentelemetry.io/docs/languages/js/getting-started/nodejs/), giving power users full control. ```ts title="config/otel.ts" import { defineConfig } from '@adonisjs/otel' import { BatchSpanProcessor } from '@opentelemetry/sdk-trace-base' import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http' export default defineConfig({ serviceName: 'my-app', /** * Use HTTP instead of gRPC */ traceExporter: new OTLPTraceExporter({ url: 'https://otel-collector.example.com/v1/traces', headers: { 'x-api-key': process.env.OTEL_API_KEY }, }), /** * Custom span processor with batching configuration */ spanProcessors: [ new BatchSpanProcessor(new OTLPTraceExporter(), { maxQueueSize: 2048, scheduledDelayMillis: 5000, }), ], /** * Custom resource attributes */ resourceAttributes: { 'deployment.region': 'eu-west-1', 'k8s.pod.name': process.env.POD_NAME, }, }) ``` See the [OpenTelemetry JS documentation](https://opentelemetry.io/docs/languages/js/) for all available options. ## Performance considerations OpenTelemetry adds some overhead to your application. The SDK needs to create span objects, record timing information, and export data to your collector. In most applications, this overhead is negligible, but you should be aware of it. For high-traffic production environments, consider these recommendations: - **Use sampling** to reduce the volume of traces. A `samplingRatio` of `0.1` (10%) is often sufficient to identify issues while dramatically reducing overhead and storage costs. - **Use batch processing** (the default) rather than sending spans immediately. The `BatchSpanProcessor` queues spans and sends them in batches, reducing network overhead. - **Be selective with custom spans**. Automatic instrumentation covers most needs. Only add custom spans for business-critical operations where you need additional visibility. Don't over-instrument by using a `@spanAll` decorator on every single class. See also: [OpenTelemetry Sampling documentation](https://opentelemetry.io/docs/concepts/sampling/) ## Helpers reference All helpers are available from `@adonisjs/otel/helpers`. | Helper | Description | | -------------------------------- | --------------------------------------------------------- | | `getCurrentSpan()` | Returns the currently active span, or `undefined` if none | | `setAttributes(attributes)` | Sets attributes on the current span | | `record(name, fn)` | Creates a span around a function | | `recordEvent(name, attributes?)` | Records an event on the current span | | `setUser(user)` | Sets user attributes on the current span | | `injectTraceContext(carrier)` | Injects trace context into a carrier object (headers) | | `extractTraceContext(carrier)` | Extracts trace context from a carrier object | | `otelLoggingPreset(options?)` | Returns pino-pretty options that hide OTEL fields | --- # Ace Command line This guide introduces you to the Ace command line. You will learn about the following topics: - Running Ace commands - Viewing help documentation - Creating command aliases - Running commands programmatically ## Overview Ace is AdonisJS's command line framework that powers all console commands in your application. Whether you're running database migrations, creating controllers, or building custom CLI tools, Ace provides the foundation for all command line interactions. The framework handles command parsing, argument validation, interactive prompts, and terminal output formatting, allowing you to focus on building command logic rather than dealing with CLI boilerplate. Every AdonisJS application includes Ace by default, accessible through the `ace.js` entry point file in your project root. Understanding how to use Ace effectively is essential for AdonisJS development, as you'll interact with it constantly during development and deployment. ## Running Ace commands You can execute Ace commands using the `ace.js` file located in your project root. This file serves as the entry point for all command line operations. :::warning Do not modify the `ace.js` file directly. If you need to add custom code that runs before Ace starts, put it in the `bin/console.ts` file instead. ::: ```sh node ace node ace make:controller node ace migration:run ``` ## Viewing available commands To see a list of all available commands in your application, run the ace entry point without any arguments or use the `list` command explicitly. ```sh node ace # Same as above node ace list ``` Both commands display the same help screen, showing all registered commands organized by category. :::media ![](./node_ace_list.png) ::: :::note The help output follows the [docopt](http://docopt.org/) standard, a specification for command line interfaces that ensures consistent documentation formatting across different tools. ::: ## Getting help for specific commands Every Ace command includes built-in help documentation. To view detailed information about a specific command, including its arguments, flags, and usage examples, append the `--help` flag to any command. ```sh node ace make:controller --help ``` The help screen shows the command's description, required and optional arguments, available flags with their descriptions, and usage examples. ## Controlling color output Ace automatically detects your terminal environment and disables colorful output when the terminal doesn't support ANSI colors. However, you can manually control color output using the `--ansi` flag. ```sh # Disable colors node ace list --no-ansi # Force enable colors node ace list --ansi ``` Disabling colors is useful when redirecting command output to files or when running commands in CI/CD environments that don't support colored terminal output. ## Creating command aliases Command aliases provide shortcuts for frequently used commands with specific flag combinations. This is particularly useful when you find yourself repeatedly typing the same command with the same flags. You can define aliases in the `adonisrc.ts` file using the `commandsAliases` object. Each alias maps a short name to a complete command with its flags. ```ts title="adonisrc.ts" export default defineConfig({ commandsAliases: { /** * Create a singular resourceful controller */ resource: 'make:controller --resource --singular' } }) ``` Once defined, you can use the alias name instead of typing the full command. Any additional arguments you provide are appended to the expanded command. ```sh # Using the alias node ace resource users # Expands to node ace make:controller --resource --singular users ``` ### How alias expansion works When you run a command, Ace follows this expansion process: 1. Ace checks if the command name matches any alias in the `commandsAliases` object 2. If a match is found, Ace extracts the first word from the alias value (before any spaces) and looks up the corresponding command 3. If a command exists with that name, Ace appends all remaining segments from the alias value to form the complete command 4. Finally, Ace appends any arguments or flags you provided when running the alias For example, if you run: ```sh node ace resource admin --help ``` Ace expands this to: ```sh node ace make:controller --resource --singular admin --help ``` The expansion preserves argument order and allows you to add additional flags beyond those defined in the alias. ## Running commands programmatically You can execute Ace commands from within your application code using the `ace` service. This is useful for building workflows that need to trigger commands programmatically, such as running migrations during application setup or generating files based on user actions. The `ace` service is available after your application has been booted, ensuring all necessary services and providers are loaded before command execution. ```ts import ace from '@adonisjs/core/services/ace' /** * Execute a command and get its result */ const command = await ace.exec('make:controller', [ 'users', '--resource', ]) /** * The command object contains execution details */ console.log(command.exitCode) // 0 for success, 1 for failure console.log(command.result) // Command return value console.log(command.error) // Error object if command failed ``` Before executing commands, you should verify that the command exists to avoid runtime errors. Use the `ace.hasCommand` method to check command availability. ```ts import ace from '@adonisjs/core/services/ace' /** * Boot Ace to load all registered commands * (if not already loaded) */ await ace.boot() if (ace.hasCommand('make:controller')) { await ace.exec('make:controller', [ 'users', '--resource', ]) } else { console.log('Controller command not available') } ``` The `ace.boot()` method loads all commands if they haven't been loaded already. This ensures the `hasCommand` check works correctly by verifying against the complete command registry. --- # Creating commands This guide covers creating commands using the Ace command line. You will learn about the following topics: - Creating custom commands - Configuring command metadata - Using lifecycle methods - Injecting dependencies - Handling errors - Managing long-running processes ## Creating your first command You can generate a new command using the `make:command` Ace command. This creates a basic command (within the `commands` directory) scaffolded with all the necessary boilerplate. See also: [Make command](../../reference/commands.md) ```sh node ace make:command greet # CREATE: commands/greet.ts ``` The generated file contains a command class that extends `BaseCommand`. At minimum, a command must define a `commandName` and implement the `run` method. ```ts title="commands/greet.ts" import { BaseCommand } from '@adonisjs/core/ace' export default class GreetCommand extends BaseCommand { static commandName = 'greet' static description = 'Greet a user by name' async run() { this.logger.info('Hello world!') } } ``` You can now execute your command using the command name you defined. ```sh node ace greet ``` ## Configuring command metadata Command metadata controls how your command appears in help screens and how it behaves during execution. The metadata includes the command name, description, help text, aliases, and execution options. ### Setting the command name The `commandName` property defines the name users will type to execute your command. Command names should not contain spaces and should avoid unfamiliar special characters like `*`, `&`, or slashes. ```ts title="commands/greet.ts" export default class GreetCommand extends BaseCommand { static commandName = 'greet' } ``` Command names can include namespaces by using a colon separator. This helps organize related commands together in the help output. ```ts title="commands/make/controller.ts" export default class MakeControllerCommand extends BaseCommand { /** * The command appears under the "make" namespace */ static commandName = 'make:controller' } ``` ### Writing command descriptions The command description appears in the commands list and on the help screen for your command. Keep descriptions concise and use the help text for longer explanations. ```ts title="commands/greet.ts" export default class GreetCommand extends BaseCommand { static commandName = 'greet' static description = 'Greet a user by name' } ``` ### Adding detailed help text Help text allows you to provide longer descriptions, usage examples, or additional context that doesn't fit in the brief description. Define help text as an array of strings, where each string represents a line of output. ```ts title="commands/greet.ts" export default class GreetCommand extends BaseCommand { static commandName = 'greet' static description = 'Greet a user by name' static help = [ 'The greet command is used to greet a user by name', '', 'You can also send flowers to a user, if they have an updated address', '{{ binaryName }} greet --send-flowers', ] } ``` The `{{ binaryName }}` variable substitution references the binary used to execute ace commands (typically `node ace`), ensuring your help text displays the correct command syntax regardless of how the user runs Ace. ### Defining command aliases Aliases provide alternative names for your command. This is useful when you want to offer shorter or more intuitive names for frequently used commands. ```ts title="commands/greet.ts" export default class GreetCommand extends BaseCommand { static commandName = 'greet' static aliases = ['welcome', 'sayhi'] } ``` Users can now run your command using any of the defined names. ```sh node ace greet node ace welcome node ace sayhi ``` ## Configuring command options Command options control the execution behavior of your command. These options are defined using the static `options` property and affect how Ace boots the application, handles flags, and manages the command's lifecycle. ```ts title="commands/greet.ts" import { BaseCommand } from '@adonisjs/core/ace' import type { CommandOptions } from '@adonisjs/core/types/ace' export default class GreetCommand extends BaseCommand { static commandName = 'greet' static options: CommandOptions = { startApp: false, allowUnknownFlags: false, staysAlive: false, } } ``` ### Starting the application By default, Ace does not boot your AdonisJS application when running commands. This keeps commands fast and prevents unnecessary application initialization for simple tasks that don't need application state. However, if your command needs access to models, services, or other application resources, you must tell Ace to start the app before executing the command. ```ts title="commands/send_email.ts" import { BaseCommand } from '@adonisjs/core/ace' import type { CommandOptions } from '@adonisjs/core/types/ace' export default class SendEmailCommand extends BaseCommand { static options: CommandOptions = { /** * Start the app to access models and services */ startApp: true } async run() { /** * Can now use application resources like models */ const users = await User.all() } } ``` ### Allowing unknown flags By default, Ace will display an error if you pass a flag that the command doesn't define. This strict parsing helps catch typos and incorrect flag usage. However, some commands need to accept arbitrary flags and pass them to other tools. You can disable strict flag parsing using the `allowUnknownFlags` option. ```ts title="commands/proxy.ts" import { BaseCommand } from '@adonisjs/core/ace' import type { CommandOptions } from '@adonisjs/core/types/ace' export default class ProxyCommand extends BaseCommand { static options: CommandOptions = { /** * Accept any flags and pass them to external tools */ allowUnknownFlags: true } } ``` ### Creating long-running commands Ace automatically terminates the application after your command's `run` method completes. This is the desired behavior for most commands that perform a task and exit. However, if your command needs to run indefinitely (like a queue worker or development server), you must tell Ace not to terminate the application using the `staysAlive` option. ```ts title="commands/queue_worker.ts" import { BaseCommand } from '@adonisjs/core/ace' import type { CommandOptions } from '@adonisjs/core/types/ace' export default class QueueWorkerCommand extends BaseCommand { static options: CommandOptions = { startApp: true, /** * Keep the process alive */ staysAlive: true } async run() { /** * Start processing jobs indefinitely */ await this.startJobProcessor() } } ``` See also: [Terminating the application](#terminating-the-application) and [Cleaning up before termination](#cleaning-up-before-the-app-terminates) ## Understanding command lifecycle Ace executes command lifecycle methods in a predefined order, allowing you to organize your command logic into distinct phases. Each lifecycle method serves a specific purpose in the command execution flow. ```ts title="commands/greet.ts" import { BaseCommand } from '@adonisjs/core/ace' export default class GreetCommand extends BaseCommand { /** * Runs first - set up initial state */ async prepare() { console.log('preparing') } /** * Runs second - interact with the user */ async interact() { console.log('interacting') } /** * Runs third - execute main command logic */ async run() { console.log('running') } /** * Runs last - cleanup and error handling */ async completed() { console.log('completed') } } ``` The following table describes each lifecycle method and its intended use: | Method | Description | |--------|-------------| | `prepare` | The first method Ace executes. Use this to initialize state or data needed by subsequent methods. | | `interact` | Executed after `prepare`. Use this to display prompts and collect user input. | | `run` | The command's main logic. This method is called after `interact`. | | `completed` | Called after all other lifecycle methods. Use this to perform cleanup or handle errors from previous methods. | You don't need to implement all lifecycle methods. Only define the methods your command actually needs. For simple commands, implementing just the `run` method is sufficient. ## Using dependency injection Ace constructs and executes commands using the [IoC container](../concepts/dependency_injection.md), enabling you to inject dependencies into any lifecycle method. This is particularly useful for accessing services, repositories, or other resources your command needs. To inject dependencies, type-hint them as method parameters and decorate the method with the `@inject` decorator. ```ts title="commands/greet.ts" import { inject } from '@adonisjs/core' import { BaseCommand } from '@adonisjs/core/ace' import UserService from '#services/user_service' export default class GreetCommand extends BaseCommand { /** * Inject UserService into the prepare method */ @inject() async prepare(userService: UserService) { // Use the injected service } /** * Dependencies can be injected into any lifecycle method */ @inject() async interact(userService: UserService) { } @inject() async run(userService: UserService) { } @inject() async completed(userService: UserService) { } } ``` The container automatically resolves dependencies, including nested dependencies, making it easy to access your application's services without manual instantiation. ## Handling errors and exit codes When an exception is thrown from your command, Ace displays the error using the CLI logger and sets the command's exit code to `1`, indicating failure. A non-zero exit code signals to the shell or CI/CD system that the command failed. However, you can also handle errors explicitly using try/catch blocks or the `completed` lifecycle method. When handling errors yourself, you must update the command's `exitCode` and `error` properties to ensure the command reports its status correctly. ### Handling errors with try/catch Use try/catch blocks to handle errors directly in the method where they might occur. This gives you fine-grained control over error handling. ```ts title="commands/greet.ts" import { BaseCommand } from '@adonisjs/core/ace' export default class GreetCommand extends BaseCommand { async run() { try { await runSomeOperation() } catch (error) { /** * Log the error message */ this.logger.error(error.message) /** * Update command state to indicate failure */ this.error = error this.exitCode = 1 } } } ``` ### Handling errors in the completed method The `completed` lifecycle method provides a centralized place to handle errors from any previous lifecycle method. This is useful when you want consistent error handling across all command phases. ```ts title="commands/greet.ts" import { BaseCommand } from '@adonisjs/core/ace' export default class GreetCommand extends BaseCommand { async run() { /** * If this throws, the error will be available in completed() */ await runSomeOperation() } async completed() { if (this.error) { /** * Handle the error from any lifecycle method */ this.logger.error(this.error.message) /** * Return true to notify Ace that you've handled the error * This prevents Ace from logging the error again */ return true } } } ``` ## Terminating the application Ace automatically terminates the application after executing your command. However, when you enable the `staysAlive` option for long-running commands, you must explicitly terminate the application when your command is done or when an error occurs. Use the `this.terminate` method to shut down the application gracefully. This is commonly used in long-running processes that need to exit based on specific conditions. ```ts title="commands/monitor_redis.ts" import { BaseCommand } from '@adonisjs/core/ace' import type { CommandOptions } from '@adonisjs/core/types/ace' export default class MonitorRedisCommand extends BaseCommand { static options: CommandOptions = { startApp: true, staysAlive: true } async run() { const redis = createRedisConnection() /** * Terminate the application when the connection fails */ redis.on('error', (error) => { this.logger.error(error) this.terminate() }) /** * Start monitoring Redis */ redis.monitor() } } ``` ## Cleaning up before the app terminates Multiple events can trigger application termination, including the [`SIGTERM` signal](https://www.howtogeek.com/devops/linux-signals-hacks-definition-and-more/) sent by process managers or when the user presses Ctrl+C. To ensure your command performs necessary cleanup before shutdown, listen for the `terminating` hook. The `terminating` hook should be registered in the `prepare` lifecycle method, which runs before your command's main logic. This ensures the cleanup handler is in place before any work begins. ```ts title="commands/queue_worker.ts" import { BaseCommand } from '@adonisjs/core/ace' import type { CommandOptions } from '@adonisjs/core/types/ace' export default class QueueWorkerCommand extends BaseCommand { static options: CommandOptions = { startApp: true, staysAlive: true } prepare() { /** * Register cleanup logic that runs before termination */ this.app.terminating(() => { /** * Close database connections, flush logs, etc. */ this.logger.info('Shutting down gracefully...') }) } async run() { /** * Start long-running work */ await this.processJobs() } } ``` --- # Command arguments This guide covers defining command arguments within custom commands. You will learn about the following topics: - Defining positional arguments - Making arguments optional - Accepting multiple values - Transforming argument values ## Overview Arguments are positional values that users provide after the command name when executing a command. Unlike flags, which can be specified in any order, arguments must be provided in the exact order they are defined in your command class. For example, in the command `node ace make:controller users --resource`, the word `users` is an argument, while `--resource` is a flag. Arguments are ideal for required input values that have a natural order, such as filenames, resource names, or entity identifiers. ## Defining your first argument You define command arguments as class properties decorated with the `@args` decorator. Ace will accept arguments in the same order as they appear in your class, making the property order significant. The most common argument type is a string argument, which accepts any text value. Use the `@args.string` decorator to define string arguments. ```ts title="commands/greet.ts" import { BaseCommand, args } from '@adonisjs/core/ace' export default class GreetCommand extends BaseCommand { static commandName = 'greet' static description = 'Greet a user by name' /** * Define a required string argument */ @args.string() declare name: string async run() { this.logger.info(`Hello, ${this.name}!`) } } ``` Users can now run your command by providing a value for the name argument. ```sh node ace greet John # Output: Hello, John! ``` If the user forgets to provide the required argument, Ace will display an error message indicating which argument is missing. ## Accepting multiple values Some commands need to accept multiple values for a single argument. For example, a command that processes multiple files might accept any number of filenames. Use the `@args.spread` decorator to accept multiple values. The spread argument must be the last argument in your command, as it captures all remaining values. ```ts title="commands/greet.ts" import { BaseCommand, args } from '@adonisjs/core/ace' export default class GreetCommand extends BaseCommand { static commandName = 'greet' static description = 'Greet multiple users by name' /** * Accept multiple names as an array */ @args.spread() declare names: string[] async run() { this.names.forEach((name) => { this.logger.info(`Hello, ${name}!`) }) } } ``` Users can now provide any number of values when running the command. ```sh node ace greet John Jane Bob # Output: # Hello, John! # Hello, Jane! # Hello, Bob! ``` ## Customizing argument name and description The argument name appears in help screens and error messages. By default, Ace uses the dashed-case version of your property name as the argument name. For example, a property named `userName` becomes `user-name` in the help output. You can customize the argument name using the `argumentName` option. ```ts title="commands/greet.ts" @args.string({ argumentName: 'user-name' }) declare name: string ``` Adding a description helps users understand what value they should provide. The description appears in the help screen when users run `node ace greet --help`. ```ts title="commands/greet.ts" @args.string({ argumentName: 'user-name', description: 'Name of the user to greet' }) declare name: string ``` ## Making arguments optional All arguments are required by default, ensuring users provide necessary input before your command executes. However, you can make an argument optional by setting the `required` option to `false`. Optional arguments must come after all required arguments. This ordering requirement prevents ambiguity in argument parsing. ```ts title="commands/greet.ts" import { BaseCommand, args } from '@adonisjs/core/ace' export default class GreetCommand extends BaseCommand { static commandName = 'greet' @args.string({ description: 'Name of the user to greet' }) declare name: string /** * Optional greeting message with custom wording */ @args.string({ description: 'Custom greeting message', required: false, }) declare message?: string async run() { const greeting = this.message || 'Hello' this.logger.info(`${greeting}, ${this.name}!`) } } ``` Now users can run the command with or without the second argument. ```sh node ace greet John # Output: Hello, John! node ace greet John "Good morning" # Output: Good morning, John! ``` ### Providing default values You can specify a default value for optional arguments using the `default` property. When users don't provide a value, Ace uses the default instead. ```ts title="commands/greet.ts" @args.string({ description: 'Name of the user to greet', required: false, default: 'guest' }) declare name: string ``` With a default value, the argument becomes optional but your code can always expect a string value rather than handling undefined. ```sh node ace greet # Uses the default value "guest" # Output: Hello, guest! ``` ## Transforming argument values The `parse` method allows you to transform or validate the argument value before it's assigned to the class property. This is useful for normalizing input, converting types, or performing validation. The parse method receives the raw string value from the command line and must return the transformed value. ```ts title="commands/greet.ts" @args.string({ argumentName: 'user-name', description: 'Name of the user to greet', parse(value) { /** * Convert the name to uppercase */ return value ? value.toUpperCase() : value } }) declare name: string ``` Now when users provide a name, it will automatically be converted to uppercase before your command's `run` method executes. ```sh node ace greet john # The name is transformed to "JOHN" # Output: Hello, JOHN! ``` You can also use the parse method for validation by throwing an error when the value is invalid. ```ts title="commands/create_user.ts" @args.string({ description: 'Email address of the user', parse(value) { if (!value.includes('@')) { throw new Error('Please provide a valid email address') } return value.toLowerCase() } }) declare email: string ``` ## Accessing all arguments You can access all arguments provided by the user, including their raw values, using the `this.parsed.args` property. This is useful for debugging or when you need to inspect the complete argument list. ```ts title="commands/greet.ts" import { BaseCommand, args } from '@adonisjs/core/ace' export default class GreetCommand extends BaseCommand { static commandName = 'greet' static description = 'Greet a user by name' @args.string() declare name: string async run() { /** * Access all arguments as a key-value object */ console.log(this.parsed.args) // Output: { name: 'John' } this.logger.info(`Hello, ${this.name}!`) } } ``` --- # Command flags This guide covers defining command flags within custom commands. You will learn about the following topics: - Defining boolean, string, number, and array flags - Customizing flag names and descriptions - Creating flag aliases for shorthand usage - Setting default values for flags - Transforming and validating flag values - Accessing all provided flags ## Overview Flags provide a way to accept optional or named parameters without requiring a specific order. They are specified with either two hyphens (`--`) for full names or a single hyphen (`-`) for aliases. In the following command, both `--resource` and `--singular` are flags. ```sh node ace make:controller users --resource --singular ``` Unlike positional arguments, flags can appear anywhere in the command and can be omitted entirely if they're optional. This makes flags ideal for options that customize command behavior, such as enabling features, specifying output formats, or providing configuration values. Ace supports multiple flag types including boolean flags for on/off options, string flags for text values, number flags for numeric input, and array flags for multiple values. ## Defining boolean flags Boolean flags represent on/off or yes/no options. They are the simplest flag type and don't require a value - simply mentioning the flag sets it to `true`. Use the `@flags.boolean` decorator to define a boolean flag. ```ts title="commands/make_controller.ts" import { BaseCommand, flags } from '@adonisjs/core/ace' export default class MakeControllerCommand extends BaseCommand { static commandName = 'make:controller' /** * Enable resource controller generation */ @flags.boolean() declare resource: boolean /** * Create a singular resource controller */ @flags.boolean() declare singular: boolean async run() { if (this.resource) { this.logger.info('Creating a resource controller') } } } ``` When users mention the flag, its value becomes `true`. If they omit the flag, its value is `undefined`. ```sh node ace make:controller users --resource # this.resource === true node ace make:controller users # this.resource === undefined ``` ### Negating boolean flags Boolean flags support negation using the `--no-` prefix, allowing users to explicitly set a flag to `false`. This is useful when a flag has a default value of `true` and users need to disable it. ```sh node ace make:controller users --no-resource # this.resource === false ``` By default, the negated variant is not shown in help screens to keep output concise. You can display it using the `showNegatedVariantInHelp` option. ```ts title="commands/make_controller.ts" @flags.boolean({ showNegatedVariantInHelp: true, }) declare resource: boolean ``` ## Defining string flags String flags accept text values that users provide after the flag name. Use the `@flags.string` decorator to define string flags. ```ts title="commands/make_controller.ts" import { BaseCommand, flags } from '@adonisjs/core/ace' export default class MakeControllerCommand extends BaseCommand { static commandName = 'make:controller' /** * The model name to associate with the controller */ @flags.string() declare model: string async run() { if (this.model) { this.logger.info(`Creating controller for ${this.model} model`) } } } ``` Users provide the value after the flag name, separated by a space or equals sign. ```sh node ace make:controller users --model user # this.model = 'user' node ace make:controller users --model=user # this.model = 'user' ``` If the flag value contains spaces or special characters, users must wrap it in quotes. ```sh node ace make:controller posts --model blog user # this.model = 'blog' # (only takes the first word) node ace make:controller posts --model "blog user" # this.model = 'blog user' # (captures the full phrase) ``` Ace will display an error if users mention the flag but don't provide a value, even when the flag is optional. ```sh node ace make:controller users # Works - optional flag is not mentioned node ace make:controller users --model # Error: Missing value for flag --model ``` ## Defining number flags Number flags are similar to string flags but Ace validates that the provided value is a valid number. This ensures your command receives numeric input rather than arbitrary text. Use the `@flags.number` decorator to define number flags. ```ts title="commands/create_user.ts" import { BaseCommand, flags } from '@adonisjs/core/ace' export default class CreateUserCommand extends BaseCommand { static commandName = 'create:user' /** * Initial score for the new user */ @flags.number() declare score: number async run() { this.logger.info(`Creating user with score: ${this.score}`) } } ``` Users must provide a valid numeric value. ```sh node ace create:user --score 100 # this.score = 100 node ace create:user --score abc # Error: Flag --score must be a valid number ``` ## Defining array flags Array flags allow users to specify the same flag multiple times, collecting all values into an array. This is useful when a command needs to accept multiple items of the same type, such as file paths, tags, or permission groups. Use the `@flags.array` decorator to define array flags. ```ts title="commands/create_user.ts" import { BaseCommand, flags } from '@adonisjs/core/ace' export default class CreateUserCommand extends BaseCommand { static commandName = 'create:user' /** * Groups to assign to the user */ @flags.array() declare groups: string[] async run() { this.logger.info(`Assigning user to groups: ${this.groups.join(', ')}`) } } ``` Users can specify the flag multiple times to build up the array. ```sh node ace create:user --groups=admin --groups=moderators --groups=creators # this.groups = ['admin', 'moderators', 'creators'] ``` ## Customizing flag names and descriptions By default, Ace converts your property name to dashed-case for the flag name. For example, a property named `startServer` becomes `--start-server`. You can customize this using the `flagName` option. ```ts title="commands/serve.ts" @flags.boolean({ flagName: 'server' }) declare startServer: boolean ``` Adding a description helps users understand the flag's purpose. The description appears in help screens when users run your command with the `--help` flag. ```ts title="commands/serve.ts" @flags.boolean({ flagName: 'server', description: 'Start the application server after the build' }) declare startServer: boolean ``` ## Creating flag aliases Flag aliases provide shorthand names for flags, making commands faster to type for frequently used options. Aliases use a single hyphen (`-`) and must be a single character. ```ts title="commands/make_controller.ts" @flags.boolean({ alias: 'r', description: 'Generate a resource controller' }) declare resource: boolean @flags.boolean({ alias: 's', description: 'Create a singular resource controller' }) declare singular: boolean ``` Users can use either the full flag name or the alias. ```sh node ace make:controller users --resource --singular # Same as node ace make:controller users -r -s ``` Multiple single-character aliases can be combined after a single hyphen. ```sh node ace make:controller users -rs # Equivalent to --resource --singular ``` ## Setting default values You can specify default values for flags using the `default` option. When users don't provide the flag, Ace uses the default value instead of `undefined`. ```ts title="commands/build.ts" @flags.boolean({ default: true, description: 'Start the application server after build' }) declare startServer: boolean @flags.string({ default: 'sqlite', description: 'Database connection to use' }) declare connection: string ``` Default values ensure your command always has a value to work with, even when users don't specify the flag. ```sh node ace build # this.startServer = true (default) # this.connection = 'sqlite' (default) node ace build --no-start-server --connection=mysql # this.startServer = false (explicitly set) # this.connection = 'mysql' (explicitly set) ``` ## Transforming flag values The `parse` method allows you to transform or validate flag values before they're assigned to your class property. This is useful for normalizing input, looking up configuration values, or performing validation. The parse method receives the raw string value and must return the transformed value. ```ts title="commands/migrate.ts" @flags.string({ description: 'Database connection to use', parse(value) { /** * Map short names to full connection strings */ const connections = { pg: 'postgresql://localhost/myapp', mysql: 'mysql://localhost/myapp', sqlite: 'sqlite://./database.sqlite' } return value ? connections[value] || value : value } }) declare connection: string ``` Now users can provide short connection names that get expanded to full connection strings. ```sh node ace migrate --connection=pg # this.connection = 'postgresql://localhost/myapp' ``` You can also use the parse method to validate input and throw errors for invalid values. ```ts title="commands/deploy.ts" @flags.string({ description: 'Deployment environment', parse(value) { const validEnvironments = ['development', 'staging', 'production'] if (value && !validEnvironments.includes(value)) { throw new Error(`Environment must be one of: ${validEnvironments.join(', ')}`) } return value } }) declare environment: string ``` ## Accessing all flags You can access all flags provided by the user using the `this.parsed.flags` property. This returns an object containing all flag values as key-value pairs. ```ts title="commands/make_controller.ts" import { BaseCommand, flags } from '@adonisjs/core/ace' export default class MakeControllerCommand extends BaseCommand { static commandName = 'make:controller' @flags.boolean() declare resource: boolean @flags.boolean() declare singular: boolean async run() { /** * Access all defined flags */ console.log(this.parsed.flags) // Output: { resource: true, singular: false } /** * Access flags that were mentioned but not defined * (only available when allowUnknownFlags is true) */ console.log(this.parsed.unknownFlags) // Output: ['--some-unknown-flag'] } } ``` The `unknownFlags` property is particularly useful when you've enabled `allowUnknownFlags` in your command options and need to process or pass through flags that your command doesn't explicitly define. --- # Prompts This guide covers using prompts within custom commands. You will learn about the following topics: - Displaying text and password input prompts - Creating single and multi-select choice lists - Using confirmation and toggle prompts - Validating and transforming user input - Using autocomplete for searchable lists - Testing commands with prompts ## Overview Prompts enable interactive command line experiences by allowing users to provide input through intuitive terminal widgets rather than command line arguments or flags. This is particularly useful for commands that need to guide users through multi-step processes, collect sensitive information like passwords, or allow selection from a list of options. Ace prompts are powered by the [@poppinss/prompts](https://github.com/poppinss/prompts) package, which supports multiple prompt types including text input, password fields, confirmations, single and multi-select lists, and autocomplete searches. All prompts support validation, default values, and transformation of user input before it's returned to your command. A key feature of Ace prompts is their [testing support](../testing/console_tests.md). When writing tests, you can trap prompts and respond to them programmatically, making it easy to test interactive commands without manual input. ## Displaying text input The text input prompt accepts free-form text from users. Use the `this.prompt.ask` method to display a text input prompt, providing the prompt message as the first parameter. ```ts title="commands/make_model.ts" import { BaseCommand } from '@adonisjs/core/ace' export default class MakeModelCommand extends BaseCommand { static commandName = 'make:model' async run() { /** * Ask for the model name */ const modelName = await this.prompt.ask('Enter the model name') this.logger.info(`Creating model: ${modelName}`) } } ``` ### Adding validation You can validate user input by providing a `validate` function in the options object. The function receives the user's input and should return `true` to accept the value, or an error message string to reject it. ```ts title="commands/make_model.ts" const modelName = await this.prompt.ask('Enter the model name', { validate(value) { return value.length > 0 ? true : 'Model name is required' } }) ``` If validation fails, the prompt displays the error message and asks for input again until the user provides a valid value. ### Providing default values Default values appear as suggestions that users can accept by pressing Enter. This is useful for providing common values or sensible defaults. ```ts title="commands/make_model.ts" const modelName = await this.prompt.ask('Enter the model name', { default: 'User' }) ``` ## Collecting passwords The password prompt masks user input in the terminal, replacing each character with an asterisk or bullet point. This is essential for collecting sensitive information like passwords, API keys, or tokens. Use the `this.prompt.secure` method to display a password prompt. ```ts title="commands/setup.ts" import { BaseCommand } from '@adonisjs/core/ace' export default class SetupCommand extends BaseCommand { static commandName = 'setup' async run() { /** * Collect the database password securely */ const password = await this.prompt.secure('Enter database password') this.logger.info('Password collected securely') } } ``` You can add validation to password prompts just like text inputs. ```ts title="commands/setup.ts" const password = await this.prompt.secure('Enter account password', { validate(value) { return value.length >= 8 ? true : 'Password must be at least 8 characters long' } }) ``` ## Creating choice lists The choice prompt displays a list of options that users can navigate with arrow keys and select with Enter. This is ideal when you need users to pick from predefined options. Use the `this.prompt.choice` method to display a single-select list. The method accepts the prompt message as the first parameter and an array of choices as the second parameter. ```ts title="commands/configure.ts" import { BaseCommand } from '@adonisjs/core/ace' export default class ConfigureCommand extends BaseCommand { static commandName = 'configure' async run() { /** * Let the user select their package manager */ const packageManager = await this.prompt.choice('Select package manager', [ 'npm', 'yarn', 'pnpm' ]) this.logger.info(`Using ${packageManager}`) } } ``` ### Customizing choice display When you want the displayed text to differ from the returned value, define choices as objects with `name` and `message` properties. The `name` is what your command receives, while the `message` is what users see. ```ts title="commands/configure.ts" const driver = await this.prompt.choice('Select database driver', [ { name: 'sqlite', message: 'SQLite' }, { name: 'mysql', message: 'MySQL' }, { name: 'pg', message: 'PostgreSQL' } ]) this.logger.info(`Selected driver: ${driver}`) // If user selected "PostgreSQL", driver will be "pg" ``` ## Allowing multiple selections The multi-select prompt lets users select multiple options from a list using the spacebar to toggle selections. This is useful when users need to choose multiple features, packages, or configurations. Use the `this.prompt.multiple` method to display a multi-select list. The parameters are the same as the choice prompt. ```ts title="commands/install.ts" import { BaseCommand } from '@adonisjs/core/ace' export default class InstallCommand extends BaseCommand { static commandName = 'install:packages' async run() { /** * Let users select multiple database drivers */ const drivers = await this.prompt.multiple('Select database drivers', [ { name: 'sqlite', message: 'SQLite' }, { name: 'mysql', message: 'MySQL' }, { name: 'pg', message: 'PostgreSQL' } ]) this.logger.info(`Installing drivers: ${drivers.join(', ')}`) } } ``` The method returns an array of selected values. Users can select all, some, or none of the options. ## Confirming actions Confirmation prompts ask users to answer yes or no questions. They're essential for destructive operations or actions that need explicit user consent. Use the `this.prompt.confirm` method to display a yes/no confirmation. The method returns a boolean value. ```ts title="commands/reset.ts" import { BaseCommand } from '@adonisjs/core/ace' export default class ResetCommand extends BaseCommand { static commandName = 'db:reset' async run() { /** * Confirm before deleting data */ const shouldDelete = await this.prompt.confirm( 'Want to delete all files?' ) if (shouldDelete) { this.logger.warning('Deleting all files...') // Perform deletion } else { this.logger.info('Operation cancelled') } } } ``` ### Customizing yes/no labels If you want to customize the yes/no labels to something more contextual, use the `this.prompt.toggle` method. This method accepts an array of two strings for the yes and no labels. ```ts title="commands/reset.ts" const shouldDelete = await this.prompt.toggle( 'Want to delete all files?', ['Yup', 'Nope'] ) if (shouldDelete) { this.logger.warning('Deleting all files...') } ``` ## Using autocomplete The autocomplete prompt combines selection with search functionality, allowing users to fuzzy search through a large list of options. This is particularly useful when dealing with many choices that would be unwieldy in a standard selection list. Use the `this.prompt.autocomplete` method to display a searchable list. ```ts title="commands/select_city.ts" import { BaseCommand } from '@adonisjs/core/ace' export default class SelectCityCommand extends BaseCommand { static commandName = 'select:city' async run() { /** * Let users search and select from a large list */ const selectedCity = await this.prompt.autocomplete( 'Select your city', await this.getCitiesList() ) this.logger.info(`You selected: ${selectedCity}`) } private async getCitiesList() { /** * Return a large array of city names */ return [ 'New York', 'Los Angeles', 'Chicago', 'Houston', // ... hundreds more cities ] } } ``` Users can type to filter the list, and the prompt will show matching options based on fuzzy search. ## Understanding prompt options All prompt types accept a common set of options through the second parameter. These options allow you to customize prompt behavior, validate input, and transform return values. | Option | Accepted by | Description | |--------|-------------|-------------| | `default` | All prompts | The default value to use when no value is entered. For select, multiselect, and autocomplete prompts, the value must be the choices array index. | | `name` | All prompts | The unique name for the prompt, useful for identifying prompts in tests. | | `hint` | All prompts | Hint text to display next to the prompt, providing additional context to users. | | `result` | All prompts | Transform the prompt return value. The input value depends on the prompt type (e.g., multiselect returns an array of selected choices). | | `format` | All prompts | Live format the input value as the user types. The formatting is only applied to the CLI output, not the return value. | | `validate` | All prompts | Validate user input. Return `true` to accept the value, or a string error message to reject it. | | `limit` | `autocomplete` | Limit the number of options to display. Users will need to scroll to see additional options. | ### Transforming return values The `result` function transforms the value returned by the prompt. This is useful for converting user input to a different format or type. ```ts title="commands/make_model.ts" const modelName = await this.prompt.ask('Enter the model name', { result(value) { /** * Convert to PascalCase for class names */ return value.charAt(0).toUpperCase() + value.slice(1) } }) ``` ### Formatting display values The `format` function changes how the input appears in the terminal as users type, without affecting the actual return value. ```ts title="commands/configure.ts" const email = await this.prompt.ask('Enter your email', { format(value) { /** * Display email in lowercase as user types */ return value.toLowerCase() } }) ``` ### Adding hints Hints provide additional context or instructions that appear next to the prompt. ```ts title="commands/make_migration.ts" const tableName = await this.prompt.ask('Enter table name', { hint: 'Use plural form (e.g., users, posts)' }) ``` --- # Terminal UI This guide covers different aspects of Terminal UIs. You will learn about the following topics: - Displaying log messages with different severity levels - Adding loading animations and action indicators - Formatting text with colors - Rendering tables with custom alignment - Creating boxed content with stickers - Building animated task runners with progress updates ## Overview The Ace terminal UI is powered by the [@poppinss/cliui](https://github.com/poppinss/cliui) package, which provides helpers for displaying logs, rendering tables, showing animated tasks, and more. All terminal UI primitives are built with testing in mind. When writing tests, you can enable "raw" mode to disable colors and formatting, making it easy to collect logs in memory and write assertions against them. This design ensures your commands remain testable while delivering rich visual experiences to users. ## Displaying log messages The CLI logger provides methods for displaying messages at different severity levels. Each log level uses distinct colors and icons to help users quickly identify message importance. ```ts title="commands/deploy.ts" import { BaseCommand } from '@adonisjs/core/ace' export default class DeployCommand extends BaseCommand { static commandName = 'deploy' async run() { /** * Debug message - helpful for troubleshooting */ this.logger.debug('Loading deployment configuration') /** * Info message - general information */ this.logger.info('Deploying application to production') /** * Success message - operation completed successfully */ this.logger.success('Deployment completed successfully') /** * Warning message - potential issues */ this.logger.warning('SSL certificate expires in 30 days') /** * Error and fatal messages - written to stderr */ this.logger.error(new Error('Failed to upload assets')) this.logger.fatal(new Error('Deployment failed completely')) } } ``` The `error` and `fatal` methods write to stderr rather than stdout, making it easier for users to redirect error output separately from normal output. ### Adding prefix and suffix You can add prefix and suffix text to log messages for additional context. Both prefix and suffix are displayed with reduced opacity to distinguish them from the main message. ```ts title="commands/install.ts" /** * Add a suffix showing the command being run */ this.logger.info('Installing packages', { suffix: 'npm i --production' }) /** * Add a prefix showing the process ID */ this.logger.info('Starting worker', { prefix: process.pid }) ``` ### Creating loading animations Loading animations display animated dots after a message, providing visual feedback during long-running operations. You can update the message text and stop the animation when the operation completes. ```ts title="commands/build.ts" /** * Create a loading animation */ const animation = this.logger.await('Installing packages', { suffix: 'npm i' }) /** * Start the animation */ animation.start() /** * Update the message as progress continues */ setTimeout(() => { animation.update('Unpacking packages', { suffix: undefined }) }, 2000) /** * Stop the animation when complete */ setTimeout(() => { animation.stop() this.logger.success('Installation complete') }, 4000) ``` ### Displaying action status Logger actions provide a consistent way to display the status of operations with automatic styling and color coding. This is particularly useful when performing multiple sequential tasks. ```ts title="commands/setup.ts" /** * Create an action indicator */ const createFile = this.logger.action('creating config/auth.ts') try { await this.createConfigFile() /** * Mark the action as succeeded * Optional: display how long it took */ createFile.displayDuration().succeeded() } catch (error) { /** * Mark the action as failed with the error */ createFile.failed(error) } ``` Actions can be marked with three different states: ```ts title="commands/setup.ts" /** * Operation completed successfully */ action.succeeded() /** * Operation was skipped with a reason */ action.skipped('File already exists') /** * Operation failed with an error */ action.failed(new Error('Permission denied')) ``` ## Formatting text with colors Ace uses [kleur](https://www.npmjs.com/package/kleur) for applying ANSI color codes to text. Access kleur's chained API through the `this.colors` property to format text with foreground colors, background colors, and text styles. ```ts title="commands/status.ts" import { BaseCommand } from '@adonisjs/core/ace' export default class StatusCommand extends BaseCommand { static commandName = 'status' async run() { /** * Apply foreground colors */ this.logger.info(this.colors.red('[ERROR]')) this.logger.info(this.colors.green('[SUCCESS]')) this.logger.info(this.colors.yellow('[WARNING]')) /** * Combine background and foreground colors */ this.logger.info(this.colors.bgGreen().white(' CREATED ')) this.logger.info(this.colors.bgRed().white(' FAILED ')) /** * Apply text styles */ this.logger.info(this.colors.bold('Important message')) this.logger.info(this.colors.dim('Less important details')) } } ``` ## Rendering tables Tables organize data into rows and columns, making it easy for users to scan and compare information. Create a table using the `this.ui.table` method, which returns a `Table` instance for defining headers and rows. ```ts title="commands/list_migrations.ts" import { BaseCommand } from '@adonisjs/core/ace' export default class ListMigrationsCommand extends BaseCommand { static commandName = 'migration:list' async run() { /** * Create a new table */ const table = this.ui.table() /** * Define table headers */ table.head([ 'Migration', 'Duration', 'Status', ]) /** * Add table rows */ table.row([ '1590591892626_tenants.ts', '2ms', 'DONE' ]) table.row([ '1590595949171_entities.ts', '2ms', 'DONE' ]) /** * Render the table to the terminal */ table.render() } } ``` You can apply color formatting to any table cell by wrapping values with color methods. ```ts title="commands/list_migrations.ts" table.row([ '1590595949171_entities.ts', '2ms', this.colors.green('DONE') ]) table.row([ '1590595949172_users.ts', '5ms', this.colors.red('FAILED') ]) ``` ### Right-aligning columns By default, all columns are left-aligned. You can right-align columns by defining them as objects with an `hAlign` property. When right-aligning a column, make sure to also right-align the corresponding header. ```ts title="commands/list_migrations.ts" /** * Right-align the status column header */ table.head([ 'Migration', 'Batch', { content: 'Status', hAlign: 'right' }, ]) /** * Right-align the status column data */ table.row([ '1590595949171_entities.ts', '2', { content: this.colors.green('DONE'), hAlign: 'right' } ]) ``` ### Rendering full-width tables By default, tables automatically size columns to fit their content. However, you can render tables at full terminal width using the `fullWidth` method. In full-width mode, all columns except one use their content width, while the designated "fluid" column expands to fill remaining space. By default, the first column is fluid. ```ts title="commands/list_files.ts" /** * Render table at full terminal width */ table.fullWidth().render() ``` You can change which column expands to fill available space using the `fluidColumnIndex` method. ```ts title="commands/list_files.ts" /** * Make the second column (index 1) fluid instead */ table .fullWidth() .fluidColumnIndex(1) .render() ``` ## Creating boxed content with stickers Stickers render content inside a bordered box, drawing user attention to important information like server addresses, configuration instructions, or key next steps. ```ts title="commands/serve.ts" import { BaseCommand } from '@adonisjs/core/ace' export default class ServeCommand extends BaseCommand { static commandName = 'serve' async run() { /** * Create a sticker for displaying server info */ const sticker = this.ui.sticker() sticker .add('Started HTTP server') .add('') .add(`Local address: ${this.colors.cyan('http://localhost:3333')}`) .add(`Network address: ${this.colors.cyan('http://192.168.1.2:3333')}`) .render() } } ``` For displaying step-by-step instructions, use the `this.ui.instructions` method instead. This prefixes each line with an arrow symbol (`>`), making it clear these are action items. ```ts title="commands/init.ts" /** * Display post-installation instructions */ const instructions = this.ui.instructions() instructions .add('Run npm install to install dependencies') .add('Copy .env.example to .env and configure your environment') .add('Run node ace migrate to set up the database') .render() ``` ## Building animated task runners The tasks widget provides a polished UI for executing and displaying progress of multiple time-consuming operations. It supports two rendering modes: minimal (for production use) and verbose (for debugging). In minimal mode, only the currently running task is expanded to show progress updates. In verbose mode, every progress message is logged on its own line, making it easier to debug issues. ### Creating basic tasks Create a tasks widget using the `this.ui.tasks` method, then add individual tasks with the `add` method. ```ts title="commands/setup.ts" import { BaseCommand } from '@adonisjs/core/ace' export default class SetupCommand extends BaseCommand { static commandName = 'setup' async run() { /** * Create a tasks widget */ const tasks = this.ui.tasks() /** * Add tasks and execute them */ await tasks .add('clone repo', async (task) => { await this.cloneRepository() return 'Completed' }) .add('update package file', async (task) => { try { await this.updatePackageFile() return 'Updated' } catch (error) { return task.error('Unable to update package file') } }) .add('install dependencies', async (task) => { await this.installDependencies() return 'Installed' }) .run() } } ``` Each task callback must return a status message. Returning a normal string indicates success, while wrapping the return value in `task.error()` indicates failure. You can also throw an exception to mark a task as failed. ### Reporting task progress Instead of using `console.log` or `this.logger` inside task callbacks, use the `task.update` method to report progress. This ensures progress updates are displayed correctly in both minimal and verbose modes. ```ts title="commands/build.ts" /** * Helper to simulate async work */ const sleep = () => new Promise((resolve) => setTimeout(resolve, 50)) const tasks = this.ui.tasks() await tasks .add('clone repo', async (task) => { /** * Report progress as the task executes */ for (let i = 0; i <= 100; i = i + 2) { await sleep() task.update(`Downloaded ${i}%`) } return 'Completed' }) .run() ``` In minimal mode, only the latest progress message is visible. In verbose mode, all messages are logged as they occur. ### Enabling verbose mode You may want to allow users to enable verbose output for debugging. This is commonly done by accepting a `--verbose` flag. ```ts title="commands/deploy.ts" import { BaseCommand, flags } from '@adonisjs/core/ace' export default class DeployCommand extends BaseCommand { static commandName = 'deploy' /** * Accept a verbose flag */ @flags.boolean({ description: 'Enable verbose output' }) declare verbose: boolean async run() { /** * Enable verbose mode based on the flag */ const tasks = this.ui.tasks({ verbose: this.verbose }) await tasks .add('build assets', async (task) => { // Task implementation }) .run() } } ``` Users can now run your command with `--verbose` to see detailed progress logs for debugging. --- # REPL In this guide, you will learn about the following topics: - Starting and navigating the REPL session - Importing modules and accessing services - Using built-in helper methods - Accessing command history and results - Adding custom REPL methods - Working with the editor mode ## Overview The AdonisJS REPL extends the standard [Node.js REPL](https://nodejs.org/api/repl.html) with application-aware features that make it easy to interact with your codebase. Unlike the basic Node.js REPL, the AdonisJS REPL boots your application, loads its services, and provides convenient shortcuts for common tasks. The REPL is particularly useful during development for quick experimentation, debugging, and data exploration. You can import TypeScript files directly, access container services without manual imports, create class instances through the IoC container, and extend the REPL with custom methods specific to your application. ## Starting the REPL session You can start the REPL session using the `node ace repl` command. This boots your AdonisJS application and opens an interactive prompt where you can execute code. ```sh node ace repl ``` :::media ![](./node_ace_repl.png) ::: Once started, you'll see a prompt where you can type JavaScript code and press Enter to execute it. The output appears immediately on the following line, creating a fast feedback loop for testing and exploration. ## Using editor mode While the REPL is great for single-line expressions, you sometimes need to write multi-line code blocks. The editor mode allows you to write multiple lines of code before executing them. Enter editor mode by typing the `.editor` command at the REPL prompt. ```sh > (js) .editor # // Entering editor mode (Ctrl+D to finish, Ctrl+C to cancel) ``` In editor mode, you can write multiple lines of code. Press `Ctrl+D` to execute the entire code block, or press `Ctrl+C` to cancel and exit editor mode without executing anything. ```sh > (js) .editor # // Entering editor mode (Ctrl+D to finish, Ctrl+C to cancel) const users = await User.query() .where('isActive', true) .orderBy('createdAt', 'desc') .limit(10) console.log(`Found ${users.length} active users`) # // Press Ctrl+D to execute ``` ## Accessing previous results The REPL provides special variables for accessing results and errors from previously executed commands, eliminating the need to re-run code when you forget to store a value. ### Accessing the last result If you execute a statement but forget to assign its result to a variable, you can access it using the `_` (underscore) variable. ```sh > (js) helpers.string.random(32) # 'Z3y8QQ4HFpYSc39O2UiazwPeKYdydZ6M' > (js) _ # 'Z3y8QQ4HFpYSc39O2UiazwPeKYdydZ6M' > (js) _.length # 32 ``` This is particularly useful when you want to perform additional operations on a result without re-executing the original command. ### Accessing the last error Similarly, you can access any exception raised by the previous command using the `_error` variable. This is helpful for inspecting error details without cluttering your code with try/catch blocks. ```sh > (js) helpers.string.random() # Error: The value of "size" is out of range... > (js) _error.message # 'The value of "size" is out of range. It must be >= 0 && <= 2147483647. Received NaN' > (js) _error.stack # (full error stack trace) ``` ## Navigating command history The REPL maintains a history of all commands you've executed, saved in the `.adonisjs_v7_repl_history` file in your home directory. This allows you to recall and re-execute previous commands without retyping them. You can navigate through command history in two ways: - **Arrow key navigation**: Press the up arrow `↑` key to cycle through previous commands one at a time. Press the down arrow `↓` to move forward through the history. - **Search mode**: Press `Ctrl+R` to enter reverse search mode, then type characters to search for matching commands in your history. Press `Ctrl+R` again to cycle through multiple matches. ```sh > (js) [Press Ctrl+R] (reverse-i-search)`query': const users = await User.query() ``` ## Exiting the REPL session You can exit the REPL session either by typing `.exit` or by pressing `Ctrl+C` twice in quick succession to exit. ```sh > (js) .exit # Goodbye! ``` When you exit, AdonisJS performs a graceful shutdown, closing database connections and cleaning up resources before the process terminates. Note that the REPL session does not automatically reload when you modify your codebase. If you change your application code, you must exit and restart the REPL session for the changes to take effect. ## Importing modules Node.js does not support `import` statements in REPL sessions, so you must use dynamic `import()` expressions instead. When importing, you need to destructure the module exports or access specific properties. ```sh > (js) const { default: User } = await import('#models/user') # undefined > (js) await User.all() # [User, User, User, ...] ``` The syntax `const { default: User }` destructures the default export from the module. This can be verbose when you only want the default export. ### Using the importDefault helper To simplify importing default exports, the REPL provides an `importDefault` helper method that automatically extracts the default export. ```sh > (js) const User = await importDefault('#models/user') # undefined > (js) const Post = await importDefault('#models/post') # undefined > (js) await Post.query().where('published', true) # [Post, Post, Post, ...] ``` This is particularly convenient when working with models, services, or any modules that export a single default value. ## Using helper methods The REPL includes several built-in helper methods that provide shortcuts for common tasks like importing services, making class instances, and managing the REPL context. You can view all available helper methods by typing the `.ls` command. ```sh > (js) .ls # GLOBAL METHODS: importDefault Returns the default export for a module make Make class instance using "container.make" method loadApp Load "app" service in the REPL context loadEncryption Load "encryption" service in the REPL context loadHash Load "hash" service in the REPL context loadRouter Load "router" service in the REPL context loadConfig Load "config" service in the REPL context loadTestUtils Load "testUtils" service in the REPL context loadHelpers Load "helpers" module in the REPL context clear Clear a property from the REPL context p Promisify a function. Similar to Node.js "util.promisify" ``` ### Loading services Instead of manually importing services, you can use the `load*` helper methods to load them into the REPL context. ```sh > (js) await loadRouter() # Imported router. You can access it using the "router" property > (js) router.toJSON() # { routes: [...], ... } > (js) await loadHash() # Imported hash. You can access it using the "hash" property > (js) await hash.make('secret') # '$argon2id$v=19$m=65536,t=3,p=4$...' ``` Each `load*` method imports the corresponding service and makes it available as a property in the REPL context, eliminating the need for import statements. ### Making class instances The `make` method uses the [IoC container](../concepts/dependency_injection.md#constructing-a-tree-of-dependencies) to create class instances with automatic dependency injection. ```sh > (js) const userService = await make('App/Services/UserService') # undefined > (js) await userService.findById(1) # User { id: 1, email: 'user@example.com', ... } ``` This is useful when you want to test services or classes that have constructor dependencies, as the container automatically resolves and injects them. ### Promisifying functions The `p` method promisifies callback-based functions, similar to Node.js `util.promisify`. ```sh > (js) const readFile = p(fs.readFile) # undefined > (js) await readFile('package.json', 'utf8') # '{ "name": "my-app", ... }' ``` ## Adding custom REPL methods You can extend the REPL with custom methods specific to your application. This is useful for creating shortcuts that you use frequently during development, such as loading all models or seeding test data. Custom methods are typically defined in a [preload file](../../reference/adonisrc_file.md#preloads) that runs only in the REPL environment. ### Creating a REPL preload file First, generate a preload file configured to run only in the REPL environment. ```sh node ace make:preload repl -e=repl # CREATE: start/repl.ts ``` ### Defining a custom method In the preload file, use `repl.addMethod` to define custom methods. For example, let's create a method that imports all models from the `app/models` directory. ```ts title="start/repl.ts" import app from '@adonisjs/core/services/app' import repl from '@adonisjs/core/services/repl' import { fsImportAll } from '@adonisjs/core/helpers' /** * Add a method to load all models at once */ repl.addMethod( 'loadModels', async () => { /** * Import all files from the models directory */ const models = await fsImportAll(app.makePath('app/models')) /** * Make models available in the REPL context */ repl.server!.context.models = models /** * Notify the user that models are loaded */ repl.notify( 'Imported models. You can access them using the "models" property' ) /** * Display the prompt again */ repl.server!.displayPrompt() }, { description: 'Load all models from app/models directory', usage: 'await loadModels()' } ) ``` The `repl.addMethod` accepts three parameters: 1. **Method name**: The name you'll use to call the method in the REPL 2. **Implementation**: An async function that performs the action 3. **Options** (optional): An object with `description` and `usage` properties that appear in help output ### Using your custom method After restarting the REPL session, your custom method becomes available. ```sh node ace repl > (js) .ls # GLOBAL METHODS: # loadModels Load all models from app/models directory # ... > (js) await loadModels() # Imported models. You can access them using the "models" property > (js) Object.keys(models) # ['User', 'Post', 'Comment', ...] > (js) await models.User.all() # [User, User, User, ...] ``` You can define multiple custom methods in the same preload file to create a comprehensive set of development shortcuts tailored to your application's needs. --- # Introduction to testing This guide covers the testing setup in AdonisJS applications. You will learn: - About Japa, the testing framework used by AdonisJS - How testing is configured through suites and plugins - How to create and run your first test - How to filter tests by file, name, tags, or suite - How to use watch mode for rapid development - How to override environment variables for testing ## Overview AdonisJS has built-in support for testing, and all starter kits come pre-configured with a complete testing setup. You can start writing tests immediately without any additional configuration. The testing layer is powered by [Japa](https://japa.dev), a testing framework we've built and maintained for over seven years. Unlike general-purpose test runners like Jest or Vitest, Japa is purpose-built for backend applications. It runs natively in Node.js without transpilers and includes plugins specifically designed for backend testing, such as an API client for testing JSON endpoints and a filesystem plugin for managing temporary files during tests. We chose to build and maintain our own testing framework to avoid the churn that's common in the JavaScript ecosystem. Having seen the community shift from Mocha to Jest to Vitest, we're glad we invested in tooling we control and can evolve alongside AdonisJS. ## Japa and AdonisJS integration Japa integrates deeply with AdonisJS through the `@japa/plugin-adonisjs` package. This plugin extends Japa with AdonisJS-specific utilities, giving your tests access to the application instance, route helpers for computing URLs, and methods for reading and writing cookies during HTTP and browser tests. The integration means you write tests that feel native to AdonisJS rather than bolting on a generic test runner that doesn't understand your application's structure. ## Project structure AdonisJS organizes tests into suites, where each suite represents a category of tests with its own configuration. A typical project structure looks like this. ```sh tests/ ├── bootstrap.ts ├── unit/ │ └── posts_service.spec.ts └── browser/ └── posts.spec.ts ``` The `tests/bootstrap.ts` file configures Japa plugins and lifecycle hooks. Individual test files live in suite directories, and each suite can have different timeouts, plugins, and setup logic appropriate for that type of testing. ### Understanding suites A test suite groups related tests that share common characteristics. For example, unit tests run quickly and don't need an HTTP server, while browser tests require a running server and have longer timeouts to account for browser automation. Hypermedia and Inertia starter kits come with two suites pre-configured: - **unit** tests isolated pieces of code like services, utilities, and models - **browser** tests run end-to-end with Playwright, simulating real user interactions Suites are defined in your `adonisrc.ts` file. ```ts title="adonisrc.ts" { tests: { suites: [ { files: ['tests/unit/**/*.spec.ts'], name: 'unit', timeout: 2000, }, { files: ['tests/browser/**/*.spec.ts'], name: 'browser', timeout: 300000, }, ], forceExit: false, } } ``` Each suite specifies a glob pattern for locating test files, a name for filtering, and a timeout in milliseconds. Browser tests have a much longer timeout (5 minutes) because browser automation is inherently slower than in-process unit tests. ### Configuring plugins and hooks The `tests/bootstrap.ts` file is where you configure Japa plugins and define lifecycle hooks that run before and after your test suites. ```ts title="tests/bootstrap.ts" import { assert } from '@japa/assert' import app from '@adonisjs/core/services/app' import type { Config } from '@japa/runner/types' import { pluginAdonisJS } from '@japa/plugin-adonisjs' import testUtils from '@adonisjs/core/services/test_utils' import { browserClient } from '@japa/browser-client' import { authBrowserClient } from '@adonisjs/auth/plugins/browser_client' import { sessionBrowserClient } from '@adonisjs/session/plugins/browser_client' /** * Configure Japa plugins in the plugins array. * Learn more - https://japa.dev/docs/runner-config#plugins-optional */ export const plugins: Config['plugins'] = [ assert(), pluginAdonisJS(app), browserClient({ runInSuites: ['browser'] }), sessionBrowserClient(app), authBrowserClient(app), ] /** * Configure lifecycle function to run before and after all the * tests. */ export const runnerHooks: Required> = { setup: [], teardown: [], } /** * Configure suites by tapping into the test suite instance. * Learn more - https://japa.dev/docs/test-suites#lifecycle-hooks */ export const configureSuite: Config['configureSuite'] = (suite) => { if (['browser', 'functional', 'e2e'].includes(suite.name)) { return suite.setup(() => testUtils.httpServer().start()) } } ``` The `configureSuite` function allows you to add setup logic specific to certain suites. In this example, browser, functional, and e2e suites automatically start the HTTP server before tests run. ## Creating your first test Generate a new test file using the `make:test` command. ```sh node ace make:test posts/index --suite=browser ``` This creates a test file at `tests/browser/posts/index.spec.ts` with the following structure. ```ts title="tests/browser/posts/index.spec.ts" import { test } from '@japa/runner' test.group('Posts index', () => { test('display a list of all posts', async ({ assert }) => {}) }) ``` Tests are organized into groups using `test.group()`, which helps structure related tests and allows you to apply shared setup and teardown logic. Individual tests are defined with `test()` and receive a context object containing utilities like `assert` for making assertions. ## Running tests Run your entire test suite with the following command. ```sh node ace test ``` To run a specific suite, pass the suite name as an argument. ```sh node ace test unit node ace test browser ``` ### Filtering tests Japa provides several flags for running a subset of tests. ::::options :::option{name="--tests"} Filter by exact test title. ```sh # Run tests with exact title match node ace test --tests="can list all posts" ``` ::: :::option{name="--files"} Filter by test filename (matches against the end of the filename without `.spec.ts`). The `--files` flag supports wildcards for running all tests in a directory. ```sh # Run a specific test file node ace test --files="posts/index" # Run all tests in the posts directory node ace test --files="posts/*" ``` ::: :::option{name="--groups"} Filter by exact group name. ```sh # Run all tests in a specific group node ace test --groups="Posts index" ``` ::: :::option{name="--tags"} Filter by tags (prefix with `~` to exclude). ::: :::option{name="--matchAll"} Require all specified tags to match instead of any. ::: :::: ### Watch mode During development, use watch mode to automatically re-run tests when files change. ```sh node ace test --watch ``` When a test file changes, only that file's tests are re-run. When a source file changes, all tests are executed. :::tip If you're iterating on a single test, combine watch mode with the `--files` filter. This ensures any file change runs only the tests you're focused on, providing faster feedback. ::: ### Additional flags Two flags are particularly useful during development. ```sh # Stop on first failure node ace test --bail # Re-run only tests that failed in the last run node ace test --failed ``` The `--bail` flag helps when debugging a failing test by preventing subsequent tests from running. The `--failed` flag is useful for quickly verifying that your fix resolved all failures without running the entire suite. ## Environment variables AdonisJS automatically loads a `.env.test` file when running tests, merging its values with your standard `.env` file. Any variable defined in `.env.test` overrides the corresponding value from `.env`. Create a `.env.test` file in your project root to configure test-specific settings. ```dotenv title=".env.test" SESSION_DRIVER=memory ``` The memory session driver is commonly used in tests because it doesn't persist sessions between requests, ensuring test isolation. Other variables you might override include database connection settings, mail drivers, or any service configuration that should behave differently during testing. ## Next steps Now that you understand how testing is configured in AdonisJS, explore the specific testing guides: - [Browser tests](./browser_tests.md) - Browser testing with Playwright for Hypermedia and Inertia applications - [Testing APIs](./api_tests.md) - HTTP testing for JSON APIs - [CLI tests](./console_tests.md) - Testing Ace commands --- # API tests This guide covers testing JSON API endpoints in AdonisJS applications. You will learn how to: - Configure the API client and related plugins - Write tests for API endpoints using route names - Send JSON and form data with requests - Work with cookies and sessions during tests - Authenticate users using sessions or access tokens - Debug requests and responses - Assert on response status, body, headers, and more ## Overview API testing in AdonisJS uses [Japa's API client](https://japa.dev/docs/plugins/api-client) to make real HTTP requests against your application. Unlike mocked or simulated requests, the API client boots your AdonisJS server and sends actual network requests from outside in. This approach tests your entire HTTP layer—routes, middleware, controllers, and responses—exactly as they would behave in production. The API client integrates with AdonisJS features like sessions and authentication through dedicated plugins, making it straightforward to test protected endpoints and stateful interactions. ## Configuration The `api` starter kit comes pre-configured with three plugins in the `tests/bootstrap.ts` file. ```ts title="tests/bootstrap.ts" import { apiClient } from '@japa/api-client' import { authApiClient } from '@adonisjs/auth/plugins/api_client' import { sessionApiClient } from '@adonisjs/session/plugins/api_client' import type { Registry } from '../.adonisjs/client/registry/schema.d.ts' declare module '@japa/api-client/types' { interface RoutesRegistry extends Registry {} } export const plugins: Config['plugins'] = [ assert(), pluginAdonisJS(app), // [!code highlight:12] /** * Configures Japa's API client for making HTTP requests */ apiClient(), /** * Adds support for reading/writing session data during requests */ sessionApiClient(app), /** * Adds support for authenticating users during requests */ authApiClient(app), ] ``` When using sessions during tests, the session driver must be set to `memory` in your `.env.test` file. This is configured by default in the starter kit. ```dotenv title=".env.test" SESSION_DRIVER=memory ``` ## Writing your first test Let's test an account creation endpoint that validates input and creates a new user. We'll write two tests: one for validation errors and one for successful creation. The route is defined in `start/routes.ts`. ```ts title="start/routes.ts" router.post('signup', [controllers.NewAccount, 'store']) ``` The first test verifies that validation errors are returned when required fields are missing. The `client.visit()` method accepts a route name and automatically determines the HTTP method and URL pattern from your route definition. ```ts title="tests/functional/auth/signup.spec.ts" import { test } from '@japa/runner' test.group('Auth signup', () => { test('return error when required fields are not provided', async ({ client }) => { /** * Make a POST request to the signup route. * Since no data is sent, validation should fail. */ const response = await client.visit('new_account.store') response.assertStatus(422) response.assertBodyContains({ errors: [ { field: 'fullName', message: 'The fullName field must be defined', rule: 'required', }, { field: 'email', message: 'The email field must be defined', rule: 'required', }, { field: 'password', message: 'The password field must be defined', rule: 'required', }, { field: 'passwordConfirmation', message: 'The passwordConfirmation field must be defined', rule: 'required', }, ], }) }) }) ``` The second test sends valid data and verifies the user was created. You can query the database directly in your tests to verify side effects. ```ts title="tests/functional/auth/signup.spec.ts" import { test } from '@japa/runner' import User from '#models/user' test.group('Auth signup', () => { test('create user account', async ({ client, assert }) => { /** * Send JSON data using the fluent .json() method */ const response = await client.visit('new_account.store').json({ fullName: 'John doe', email: 'john@example.com', password: 'secret@123A', passwordConfirmation: 'secret@123A', }) response.assertStatus(200) response.assertBodyContains({ data: { fullName: 'John doe', email: 'john@example.com', }, }) /** * Verify the user was persisted to the database */ const user = await User.findOrFail(response.body().data.id) assert.equal(user.email, 'john@example.com') }) }) ``` ## Cleaning up database state Tests that create database records need cleanup between runs to ensure isolation. The `testUtils.db().truncate()` hook migrates the database and truncates all tables after each test. See also: [Database testing utilities](./resetting_state_between_tests.md) for additional methods like migrations and seeders. ```ts title="tests/functional/auth/signup.spec.ts" import { test } from '@japa/runner' import testUtils from '@adonisjs/core/services/test_utils' test.group('Auth signup', (group) => { /** * Truncate tables after each test to ensure * a clean state for the next test */ group.each.setup(() => { return testUtils.db().truncate() }) test('create user account', async ({ client, assert }) => { // ... }) }) ``` ## Making requests The API client provides two approaches for making HTTP requests: using route names or explicit HTTP methods. ### Using route names The `client.visit()` method accepts a route name and looks up the HTTP method and URL pattern from your router. This keeps your tests in sync with route changes and also provides type-safety within tests. ```ts const response = await client.visit('posts.store') ``` ### Using HTTP methods When you need to hit a specific URL directly, use the explicit HTTP method functions. ```ts const response = await client.get('/api/posts') const response = await client.post('/api/posts') const response = await client.put('/api/posts/1') const response = await client.patch('/api/posts/1') const response = await client.delete('/api/posts/1') ``` ## Sending request data ### JSON data Use the `json()` method to send a JSON payload. The `Content-Type` header is set automatically. ```ts const response = await client.visit('posts.store').json({ title: 'Hello World', content: 'This is my first post', }) ``` ### Form data Use the `form()` method to send URL-encoded form data. ```ts const response = await client.visit('posts.store').form({ title: 'Hello World', content: 'This is my first post', }) ``` ### Multipart data Use the `field()` method to send multipart form fields. ```ts const response = await client .visit('posts.store') .field('title', 'Hello World') .field('content', 'This is my first post') ``` ## Cookies You can set cookies on outgoing requests using the `withCookie()` method and its variants. ```ts /** * Set a regular cookie */ const response = await client .visit('checkout.store') .withCookie('affiliateId', '1') /** * Set an encrypted cookie (uses AdonisJS encryption) */ const response = await client .visit('checkout.store') .withEncryptedCookie('affiliateId', '1') /** * Set a plain cookie (no signing or encryption) */ const response = await client .visit('checkout.store') .withPlainCookie('affiliateId', '1') ``` ## Sessions The `withSession()` method populates the session store before making a request. This is useful for testing flows that depend on existing session state. ```ts const response = await client .visit('checkout.store') .withSession({ cartId: 1 }) ``` ## Authentication ### Session authentication The `loginAs()` method authenticates a user for the request using your default auth guard. You must create the user before making the authenticated request. ```ts test('create a post', async ({ client }) => { const user = await User.create({ fullName: 'John', email: 'john@example.com', password: 'secret', }) const response = await client.visit('posts.store').loginAs(user) response.assertStatus(200) }) ``` ### Token authentication When using access tokens or a different auth guard, chain the `withGuard()` method before `loginAs()` to specify which guard to use. ```ts test('create a post via API', async ({ client }) => { const user = await User.create({ fullName: 'John', email: 'john@example.com', password: 'secret', }) /** * Use the 'api' guard for token-based authentication */ const response = await client .visit('posts.store') .withGuard('api') .loginAs(user) response.assertStatus(200) }) ``` Make sure your route middleware allows authentication using the specified guard. ```ts title="start/routes.ts" router .group(() => { router.post('posts', [controllers.Posts, 'store']) }) .use(middleware.auth({ guards: ['web', 'api'] })) ``` ## Debugging ### Dumping requests Chain the `dump()` method when building a request to log the request details before it's sent. ```ts const response = await client .visit('posts.store') .dump() .json({ title: 'Hello World' }) ``` ### Dumping responses The response object provides methods to inspect what was returned. ```ts const response = await client.visit('posts.index') /** * Dump the entire response (status, headers, body) */ response.dump() /** * Dump only the response body */ response.dumpBody() /** * Dump only the response headers */ response.dumpHeaders() ``` ### Checking for server errors Use `hasFatalError()` to check if the server returned a 500-level error. ```ts const response = await client.visit('posts.store').json(data) if (response.hasFatalError()) { response.dump() } ``` ## Assertions reference The response object provides assertion methods for validating status codes, body content, headers, cookies, and session data. ### Status and body assertions | Method | Description | |--------|-------------| | `assertStatus(status)` | Assert the response status matches the expected value | | `assertBody(body)` | Assert the response body exactly matches the expected value | | `assertBodyContains(subset)` | Assert the response body contains the expected subset | | `assertBodyNotContains(subset)` | Assert the response body does not contain the subset | | `assertTextIncludes(text)` | Assert the response text includes the substring | ### Header assertions | Method | Description | |--------|-------------| | `assertHeader(name, value?)` | Assert a header exists, optionally checking its value | | `assertHeaderMissing(name)` | Assert a header does not exist | ### Cookie assertions | Method | Description | |--------|-------------| | `assertCookie(name, value?)` | Assert a cookie exists, optionally checking its value | | `assertCookieMissing(name)` | Assert a cookie does not exist | ### Redirect assertions | Method | Description | |--------|-------------| | `assertRedirectsTo(pathname)` | Assert the response redirects to the given pathname | ### Session assertions | Method | Description | |--------|-------------| | `assertSession(key, value?)` | Assert a session key exists, optionally checking its value | | `assertSessionMissing(key)` | Assert a key is missing from the session store | | `assertFlashMessage(key, value?)` | Assert a flash message exists, optionally checking its value | | `assertFlashMissing(key)` | Assert a key is missing from flash messages | ### Validation error assertions | Method | Description | |--------|-------------| | `assertHasValidationError(field)` | Assert flash messages contain validation errors for the field | | `assertDoesNotHaveValidationError(field)` | Assert flash messages do not contain validation errors for the field | | `assertValidationError(field, message)` | Assert a specific error message for a field | | `assertValidationErrors(field, messages)` | Assert all error messages for a field | ### OpenAPI assertions | Method | Description | |--------|-------------| | `assertAgainstApiSpec()` | Assert the response body is valid according to your OpenAPI specification | See also: [Japa API Client documentation](https://japa.dev/docs/plugins/api-client) --- # Browser tests This guide covers end-to-end browser testing for hypermedia and Inertia applications. You will learn how to: - Configure browser testing plugins in your test suite - Control test execution via CLI options (browsers, headed mode, traces, slow motion) - Write basic page visit tests with assertions - Reset database state between tests - Fill and submit forms using Playwright selectors - Use recording mode to generate test code quickly - Authenticate users before visiting protected pages ## Overview Browser tests verify your application from the outside-in, navigating it exactly as a real user would. Unlike unit tests that examine isolated pieces of code, browser tests exercise the entire stack: routes, controllers, views, database queries, and client-side interactions all working together. For hypermedia and Inertia applications, browser tests should form the majority of your test suite. These applications are inherently about user interactions with rendered pages, and browser tests capture this reality directly. When a browser test passes, you have high confidence that the feature actually works for users. When it fails, you've caught a bug that users would have encountered. This approach may feel different if you're accustomed to the "testing pyramid" where unit tests dominate. For server-rendered applications, inverting this pyramid makes sense: browser tests provide more value per test because they verify complete user flows rather than implementation details. ## Setup Browser testing requires three plugins configured in your `tests/bootstrap.ts` file. These are already installed and configured with the official Hypermedia and Inertia starter kits. ```ts title="tests/bootstrap.ts" import { assert } from '@japa/assert' import app from '@adonisjs/core/services/app' import type { Config } from '@japa/runner/types' import { pluginAdonisJS } from '@japa/plugin-adonisjs' import testUtils from '@adonisjs/core/services/test_utils' // [!code highlight:3] import { browserClient } from '@japa/browser-client' import { authBrowserClient } from '@adonisjs/auth/plugins/browser_client' import { sessionBrowserClient } from '@adonisjs/session/plugins/browser_client' export const plugins: Config['plugins'] = [ assert(), pluginAdonisJS(app), // [!code highlight:17] /** * Configures Playwright and creates a fresh browser * context before every test. */ browserClient({ runInSuites: ['browser'] }), /** * Allows reading and writing session data * via the browser context. */ sessionBrowserClient(app), /** * Enables the loginAs method for authenticating * users during tests. */ authBrowserClient(app), ] export const runnerHooks: Required> = { setup: [], teardown: [], } export const configureSuite: Config['configureSuite'] = (suite) => { if (['browser', 'functional', 'e2e'].includes(suite.name)) { return suite.setup(() => testUtils.httpServer().start()) } } ``` ## CLI options Playwright behavior is controlled through command-line flags when running tests. The following options help with debugging and cross-browser verification. ::::options :::option{name="--browser"} Run tests in a specific browser. Supported values are `chromium`, `firefox`, and `webkit`. ```bash node ace test --browser=firefox ``` ::: :::option{name="--headed"} Show the browser window during test execution. By default, tests run in headless mode. ```bash node ace test --headed ``` ::: :::option{name="--devtools"} Open browser devtools automatically when the browser launches. ```bash node ace test --devtools ``` ::: :::option{name="--slow"} Slow down test actions by the specified number of milliseconds. Useful for visually following what the test is doing. ```bash node ace test --slow=500 ``` ::: :::option{name="--trace"} Record traces for debugging. Use `onError` to record only when tests fail, or `onTest` to record every test. ```bash node ace test --trace=onError ``` ::: :::: ### Recording traces Traces capture a complete timeline of your test execution, including screenshots, network requests, and DOM snapshots. Generate traces only when tests fail or for every test. ```bash # Record traces only when a test fails node ace test --trace=onError # Record traces for every test node ace test --trace=onTest ``` Traces are stored in the `browsers` directory. Replay them using Playwright's trace viewer. ```bash npx playwright show-trace browsers/path-to-trace.zip ``` ### Running specific tests Run all browser tests or target specific files and folders. ```bash # Run all browser tests node ace test browser # Run tests from a specific folder node ace test --files="posts/*" ``` ## Basic page visits A browser test visits a page and makes assertions about its content. The `visit` helper opens a URL, and the returned page object provides assertion methods. ```ts title="tests/browser/posts/index.spec.ts" import { test } from '@japa/runner' test.group('Posts index', () => { test('display list of posts', async ({ visit, route }) => { /** * Visit the posts index page using its named route. * The visit helper returns a Playwright page instance * extended with assertion methods. */ const page = await visit(route('posts.index')) /** * Assert that the body contains specific text. * This will wait up to 5 seconds for the text to appear. */ await page.assertTextContains('body', 'My first post') }) }) ``` This test fails because no posts exist in the database. The failure message indicates the assertion timed out waiting for the expected content. ```sh title="❌ Output of failing test" ℹ AssertionError: expected 'body' inner text to include 'My first post', timed out after 5000ms ⁃ (AssertionError [ERR_ASSERTION]: expected 'body' inner text to include 'My first post':undefined:undefined) ``` ## Database state Tests should start with a known database state. Use the `testUtils.db().truncate()` hook to clear tables after each test, then create the specific records your test needs. See also: [Database testing utilities](./resetting_state_between_tests.md) for additional methods like migrations and seeders. ```ts title="tests/browser/posts/index.spec.ts" import Post from '#models/post' import User from '#models/user' import testUtils from '@adonisjs/core/services/test_utils' import { test } from '@japa/runner' test.group('Posts index', (group) => { /** * Truncate database tables after each test. * This ensures tests don't affect each other. */ group.each.setup(() => testUtils.db().truncate()) test('display list of posts', async ({ visit, route }) => { /** * Create the data this test depends on. * Each test sets up its own state explicitly. */ const user = await User.create({ email: 'john@example.com', password: 'secret', }) await Post.create({ title: 'My first post', content: 'This is my first post', userId: user.id, }) const page = await visit(route('posts.index')) await page.assertTextContains('body', 'My first post') }) }) ``` ## Form interactions Forms are filled using Playwright's locator methods. Select inputs by their label text and use `fill` to enter values, then `click` to submit. ```ts title="tests/browser/session/create.spec.ts" import testUtils from '@adonisjs/core/services/test_utils' import { test } from '@japa/runner' test.group('Session create', (group) => { group.each.setup(() => testUtils.db().truncate()) test('display error when invalid credentials are used', async ({ visit, route }) => { const page = await visit(route('session.create')) /** * Locate inputs by their associated label text. * This mirrors how users identify form fields. */ await page.getByLabel('Email').fill('john@example.com') await page.getByLabel('Password').fill('secret') /** * Click the submit button. getByRole finds elements * by their ARIA role, making tests resilient to * markup changes. */ await page.getByRole('button').click() }) }) ``` ### Recording mode Writing locators manually requires switching between your browser and test file repeatedly. Recording mode launches a browser where your interactions are converted to test code automatically. Create a new test file and call the `record` method instead of `visit`. When you run the test, a browser opens where you can interact with your application. Close the browser when finished, and copy the generated code into your test file. ```ts title="tests/browser/posts/create.spec.ts" import { test } from '@japa/runner' test.group('Posts create', () => { test('create a new post', async ({ record, route }) => { /** * Opens the browser in recording mode. * Test timeout is disabled while recording. * Interact with the page, then close the browser * to see the generated test code. */ await record(route('posts.create')) }) }) ``` After recording, replace the `record` call with `visit` and paste the generated locators and actions. ## Authenticating users Protected pages require an authenticated user. The `browserContext.loginAs` method authenticates a user for all subsequent page visits within that test. :::warning For authentication to work during tests, set `SESSION_DRIVER=memory` in your `.env.test` file. The memory driver allows the test process to manage sessions without file or database overhead. ::: ```ts title="tests/browser/posts/create.spec.ts" import User from '#models/user' import { test } from '@japa/runner' import testUtils from '@adonisjs/core/services/test_utils' test.group('Posts create', (group) => { group.each.setup(() => testUtils.db().truncate()) test('display error when missing post title or content', async ({ visit, browserContext, route, }) => { const user = await User.create({ email: 'john@example.com', password: 'secret', }) /** * Authenticate the user for this browser context. * All subsequent page visits will be authenticated. */ await browserContext.loginAs(user) const page = await visit(route('posts.create')) await page.assertPath('/posts/create') }) }) ``` ## Cookies and sessions The browser context provides methods to read and write cookies, sessions, and flash messages during tests. These are useful when your application behavior depends on stored state. ### Setting cookies Three methods are available depending on how the cookie should be stored. ```ts title="tests/browser/preferences.spec.ts" import { test } from '@japa/runner' test.group('User preferences', () => { test('apply dark mode from cookie', async ({ visit, browserContext, route }) => { /** * Set an encrypted cookie (default cookie behavior in AdonisJS). */ await browserContext.setCookie('theme', 'dark') /** * Set a plain cookie without encryption. * Useful for cookies that client-side JavaScript needs to read. */ await browserContext.setPlainCookie('locale', 'en-us') /** * Set an encrypted cookie explicitly. * Equivalent to setCookie. */ await browserContext.setEncryptedCookie('preferences', { sidebar: 'collapsed' }) const page = await visit(route('dashboard')) await page.assertVisible('.dark-mode') }) }) ``` ### Reading cookies Retrieve cookie values after page interactions to verify your application sets them correctly. ```ts title="tests/browser/preferences.spec.ts" import { test } from '@japa/runner' test.group('User preferences', () => { test('save theme preference to cookie', async ({ visit, browserContext, route }) => { const page = await visit(route('settings')) await page.getByLabel('Theme').selectOption('dark') await page.getByRole('button', { name: 'Save' }).click() /** * Read cookies after the page interaction. */ const theme = await browserContext.getCookie('theme') const locale = await browserContext.getPlainCookie('locale') const prefs = await browserContext.getEncryptedCookie('preferences') }) }) ``` ### Setting session data Pre-populate session data before visiting a page. This is useful when testing features that depend on session state without going through the UI to establish that state. ```ts title="tests/browser/onboarding.spec.ts" import { test } from '@japa/runner' test.group('Onboarding', () => { test('resume onboarding from step 3', async ({ visit, browserContext, route }) => { /** * Set session data before visiting the page. * The page will read this state and resume accordingly. */ await browserContext.setSession({ onboarding: { currentStep: 3, completedSteps: [1, 2] } }) const page = await visit(route('onboarding')) await page.assertTextContains('h1', 'Step 3') }) }) ``` ### Setting flash messages Flash messages are session data that persist only for the next request. Set them to test how your UI displays notifications or validation errors. ```ts title="tests/browser/notifications.spec.ts" import { test } from '@japa/runner' test.group('Notifications', () => { test('display success notification', async ({ visit, browserContext, route }) => { await browserContext.setFlashMessages({ success: 'Your changes have been saved' }) const page = await visit(route('dashboard')) await page.assertTextContains('.notification', 'Your changes have been saved') }) }) ``` ### Reading session and flash messages Verify that your application writes the expected data to the session. ```ts title="tests/browser/cart.spec.ts" import { test } from '@japa/runner' test.group('Shopping cart', () => { test('add item to cart stored in session', async ({ visit, browserContext, route }) => { const page = await visit(route('products.show', { id: 1 })) await page.getByRole('button', { name: 'Add to cart' }).click() const session = await browserContext.getSession() const flashMessages = await browserContext.getFlashMessages() }) }) ``` ## See also - [Japa browser client](https://japa.dev/docs/plugins/browser-client#switching-between-browsers) for the complete assertions API - [Playwright locators](https://playwright.dev/docs/locators) for advanced element selection strategies --- # Console tests This guide covers testing Ace commands in AdonisJS applications. You will learn how to: - Write tests for custom Ace commands - Capture and assert logger output using raw mode - Test table rendering in command output - Trap and respond to CLI prompts programmatically - Validate prompt input within tests - Use built-in assertion methods for command results ## Overview Console tests allow you to verify that your custom Ace commands behave correctly without manual interaction. Since commands often produce terminal output and prompt users for input, testing them requires special techniques to capture output and simulate user responses. AdonisJS provides a dedicated testing API through the `ace` service that lets you create command instances, execute them in isolation, and make assertions about their behavior. The API includes tools for capturing log output, intercepting prompts, and verifying exit codes. Testing commands is particularly valuable when your commands perform critical operations like database migrations, file generation, or deployment tasks. A failing command in production can have serious consequences, so automated tests help catch issues before they reach users. ## Basic example Let's walk through testing a simple command from start to finish. First, create a new command using the `make:command` generator. ```sh node ace make:command greet # DONE: create app/commands/greet.ts ``` The generated command includes a `run` method where you define the command's behavior. Update it to greet the user. ```ts title="app/commands/greet.ts" import { BaseCommand } from '@adonisjs/core/ace' import { CommandOptions } from '@adonisjs/core/types/ace' export default class Greet extends BaseCommand { static commandName = 'greet' static description = 'Greet a user by name' static options: CommandOptions = {} async run() { this.logger.info('Hello world from "Greet"') } } ``` Next, create a test file for the command. If you haven't already defined a unit test suite, see the [testing introduction](./introduction.md#suites) for setup instructions. ```sh node ace make:test commands/greet --suite=unit # DONE: create tests/unit/commands/greet.spec.ts ``` The test uses the `ace` service to create a command instance, execute it, and verify it completed successfully. The `ace.create` method accepts the command class and an array of arguments (empty in this case since the command takes no arguments). ```ts title="tests/unit/commands/greet.spec.ts" import { test } from '@japa/runner' import Greet from '#commands/greet' import ace from '@adonisjs/core/services/ace' test.group('Commands greet', () => { test('should greet and exit with code 0', async () => { /** * Create an instance of the command. The second argument * is an array of CLI arguments to pass to the command. */ const command = await ace.create(Greet, []) /** * Execute the command. This runs the `run` method. */ await command.exec() /** * Assert the command exited successfully (exit code 0). */ command.assertSucceeded() }) }) ``` Run the test using the following command. ```sh node ace test --files=commands/greet ``` ## Testing logger output The `Greet` command writes a log message to the terminal using `this.logger.info()`. By default, this output goes directly to stdout, which makes it difficult to capture and assert against in tests. To solve this, you can switch the ace UI library into **raw mode**. In raw mode, ace stores all output in memory instead of writing to the terminal. This allows you to inspect and assert against the exact messages your command produces. :::tip Raw mode captures all output from `this.logger`, `this.ui.table()`, and other UI methods. Always switch back to normal mode after your test to avoid affecting other tests. ::: Use a Japa `group.each.setup` hook to switch modes automatically before and after each test. ```ts title="tests/unit/commands/greet.spec.ts" import { test } from '@japa/runner' import Greet from '#commands/greet' import ace from '@adonisjs/core/services/ace' test.group('Commands greet', (group) => { /** * Switch to raw mode before each test. The returned function * runs after each test to restore normal mode. */ group.each.setup(() => { ace.ui.switchMode('raw') return () => ace.ui.switchMode('normal') }) test('should log greeting message', async () => { const command = await ace.create(Greet, []) await command.exec() command.assertSucceeded() /** * Assert the exact log message. In raw mode, colors are * represented as function names like `blue()`. */ command.assertLog('[ blue(info) ] Hello world from "Greet"') }) }) ``` :::warning Log assertions in raw mode include color function names. The message `this.logger.info('Hello')` becomes `[ blue(info) ] Hello` in raw mode. If your assertion fails, check that you've included the color formatting in your expected string. ::: ## Testing table output Commands often display tabular data using `this.ui.table()`. You can test table output the same way as log output by switching to raw mode first. Consider a command that displays a table of team members. ```ts title="app/commands/list_team.ts" import { BaseCommand } from '@adonisjs/core/ace' export default class ListTeam extends BaseCommand { static commandName = 'list:team' static description = 'List all team members' async run() { const table = this.ui.table() table.head(['Name', 'Email']) table.row(['Harminder Virk', 'virk@adonisjs.com']) table.row(['Romain Lanz', 'romain@adonisjs.com']) table.row(['Julien-R44', 'julien@adonisjs.com']) table.render() } } ``` Use `assertTableRows` to verify the table contents. Pass a two-dimensional array where each inner array represents a row's cells. ```ts title="tests/unit/commands/list_team.spec.ts" import { test } from '@japa/runner' import ListTeam from '#commands/list_team' import ace from '@adonisjs/core/services/ace' test.group('Commands list:team', (group) => { group.each.setup(() => { ace.ui.switchMode('raw') return () => ace.ui.switchMode('normal') }) test('should display team members table', async () => { const command = await ace.create(ListTeam, []) await command.exec() /** * Assert table rows match expected data. Each inner array * represents one row with its column values. */ command.assertTableRows([ ['Harminder Virk', 'virk@adonisjs.com'], ['Romain Lanz', 'romain@adonisjs.com'], ['Julien-R44', 'julien@adonisjs.com'], ]) }) }) ``` ## Trapping prompts [Prompts](../ace/prompts.md) pause command execution and wait for user input, which blocks automated tests. To handle this, you must **trap** prompts before executing the command. A trap intercepts a specific prompt and provides a programmatic response. Traps are created using `command.prompt.trap()`, which accepts the prompt title as its argument. The title must match exactly, including case. :::warning Prompt titles are case-sensitive. If your prompt asks `"What is your name?"` but you trap `"what is your name?"`, the trap won't match and your test will hang waiting for input. Always copy the exact prompt title from your command. ::: ### Replying to text prompts Use `replyWith` to provide a text response to prompts created with `this.prompt.ask()`. ```ts title="tests/unit/commands/greet.spec.ts" import { test } from '@japa/runner' import Greet from '#commands/greet' import ace from '@adonisjs/core/services/ace' test.group('Commands greet', (group) => { group.each.setup(() => { ace.ui.switchMode('raw') return () => ace.ui.switchMode('normal') }) test('should greet user by name', async () => { const command = await ace.create(Greet, []) /** * Trap the prompt and provide a response. This must be * set up before calling exec(). */ command.prompt.trap('What is your name?').replyWith('Virk') await command.exec() command.assertSucceeded() }) }) ``` :::tip Traps are consumed when triggered and automatically removed afterward. If a test completes without triggering a trapped prompt, Japa throws an error to alert you that the expected prompt never appeared. ::: ### Choosing options from select prompts For prompts created with `this.prompt.choice()` or `this.prompt.multiple()`, use `chooseOption` or `chooseOptions` with zero-based indices. ```ts title="tests/unit/commands/setup.spec.ts" import { test } from '@japa/runner' import Setup from '#commands/setup' import ace from '@adonisjs/core/services/ace' test.group('Commands setup', (group) => { group.each.setup(() => { ace.ui.switchMode('raw') return () => ace.ui.switchMode('normal') }) test('should configure with npm', async () => { const command = await ace.create(Setup, []) /** * Choose the first option (index 0) from a select prompt. */ command.prompt.trap('Select package manager').chooseOption(0) await command.exec() command.assertSucceeded() }) test('should select multiple databases', async () => { const command = await ace.create(Setup, []) /** * Choose multiple options by passing an array of indices. */ command.prompt.trap('Select databases to configure').chooseOptions([0, 2]) await command.exec() command.assertSucceeded() }) }) ``` ### Accepting or rejecting confirmation prompts For boolean prompts created with `this.prompt.confirm()` or `this.prompt.toggle()`, use `accept` or `reject`. ```ts title="tests/unit/commands/cleanup.spec.ts" import { test } from '@japa/runner' import Cleanup from '#commands/cleanup' import ace from '@adonisjs/core/services/ace' test.group('Commands cleanup', (group) => { group.each.setup(() => { ace.ui.switchMode('raw') return () => ace.ui.switchMode('normal') }) test('should delete files when confirmed', async () => { const command = await ace.create(Cleanup, []) command.prompt.trap('Want to delete all temporary files?').accept() await command.exec() command.assertSucceeded() }) test('should abort when rejected', async () => { const command = await ace.create(Cleanup, []) command.prompt.trap('Want to delete all temporary files?').reject() await command.exec() command.assertLog('[ blue(info) ] Cleanup cancelled') }) }) ``` ## Intermediate: Testing prompt validation Prompts can include [validation rules](../ace/prompts.md#prompt-options) that reject invalid input. You can test these validators directly using `assertPasses` and `assertFails` without fully executing the command. The `assertFails` method accepts the input value and the expected error message. The `assertPasses` method accepts a value that should pass validation. ```ts title="tests/unit/commands/create_user.spec.ts" import { test } from '@japa/runner' import CreateUser from '#commands/create_user' import ace from '@adonisjs/core/services/ace' test.group('Commands create:user', (group) => { group.each.setup(() => { ace.ui.switchMode('raw') return () => ace.ui.switchMode('normal') }) test('should validate email format', async () => { const command = await ace.create(CreateUser, []) /** * Test validation without executing the full command. * Chain multiple assertions to test various inputs. */ command.prompt .trap('Enter your email') .assertFails('', 'Email is required') .assertFails('invalid', 'Please enter a valid email') .assertPasses('user@example.com') .replyWith('admin@adonisjs.com') await command.exec() command.assertSucceeded() }) }) ``` You can chain validation assertions with `replyWith` to both test the validator and provide a final response. ## Available assertions The command instance provides several assertion methods to verify command behavior. ::::options :::option{name="assertSucceeded"} Assert the command exited with `exitCode=0`, indicating success. ```ts title="tests/unit/commands/greet.spec.ts" await command.exec() command.assertSucceeded() ``` ::: :::option{name="assertFailed"} Assert the command exited with a non-zero `exitCode`, indicating failure. ```ts title="tests/unit/commands/greet.spec.ts" await command.exec() command.assertFailed() ``` ::: :::option{name="assertExitCode"} Assert the command exited with a specific exit code. Useful when your command uses different exit codes to signal different error conditions. ```ts title="tests/unit/commands/greet.spec.ts" await command.exec() command.assertExitCode(2) ``` ::: :::option{name="assertNotExitCode"} Assert the command did not exit with a specific code. ```ts title="tests/unit/commands/greet.spec.ts" await command.exec() command.assertNotExitCode(1) ``` ::: :::option{name="assertLog"} Assert the command wrote a specific log message. Optionally specify the output stream as `stdout` or `stderr`. Requires raw mode. ```ts title="tests/unit/commands/greet.spec.ts" await command.exec() command.assertLog('[ blue(info) ] Task completed') command.assertLog('[ red(error) ] Something went wrong', 'stderr') ``` ::: :::option{name="assertLogMatches"} Assert the command wrote a log message matching a regular expression. Useful when the exact message varies but follows a pattern. Requires raw mode. ```ts title="tests/unit/commands/greet.spec.ts" await command.exec() command.assertLogMatches(/Task completed in \d+ms/) ``` ::: :::option{name="assertTableRows"} Assert the command printed a table with specific rows. Pass a two-dimensional array where each inner array represents a row's column values. Requires raw mode. ```ts title="tests/unit/commands/list_users.spec.ts" await command.exec() command.assertTableRows([ ['1', 'Alice', 'alice@example.com'], ['2', 'Bob', 'bob@example.com'], ]) ``` ::: :::: ## See also - [Ace prompts](../ace/prompts.md) for details on creating interactive prompts - [Creating commands](../ace/creating_commands.md) for building custom Ace commands - [Testing introduction](./introduction.md) for configuring test suites and runners --- # Resetting state between tests This guide covers managing application state during testing in AdonisJS. You will learn how to: - Migrate and seed the database before running tests - Clean up database state between individual tests using transactions or truncation - Manage filesystem state with automatic cleanup - Reset Redis data between tests - Configure separate test databases using environment overrides ## Overview Tests that modify application state, such as creating database records, uploading files, or caching data in Redis, need a strategy for resetting that state between test runs. Without proper cleanup, tests can interfere with each other, leading to flaky results that pass or fail depending on execution order. AdonisJS provides utilities through the `testUtils` service that handle common state management patterns. The general approach is to run database migrations once before all tests, then reset data between individual tests using either transactions or truncation. For filesystem and Redis, similar patterns ensure each test starts with a clean slate. :::warning Make sure your test environment is configured to use separate databases and storage systems from your development and production environments. Running tests against production data can result in data loss. You can use the `.env.test` file to override environment variables specifically for tests. ::: ## Database state management ### Migrating the database Register a global setup hook in `tests/bootstrap.ts` to run migrations before any tests execute. The `testUtils.db().migrate()` method applies all pending migrations to prepare the database schema. ```ts title="tests/bootstrap.ts" import testUtils from '@adonisjs/core/services/test_utils' export const runnerHooks: Required> = { setup: [() => testUtils.db().migrate()], teardown: [], } ``` If your application uses multiple database connections, pass the connection name to target a specific database. ```ts title="tests/bootstrap.ts" export const runnerHooks: Required> = { setup: [ () => testUtils.db().migrate(), // [!code highlight] () => testUtils.db('tenant').migrate(), ], teardown: [], } ``` ### Seeding the database If your tests require seed data, add the `seed()` hook after migration. This runs your database seeders to populate tables with initial data. ```ts title="tests/bootstrap.ts" import testUtils from '@adonisjs/core/services/test_utils' export const runnerHooks: Required> = { setup: [ () => testUtils.db().migrate(), // [!code highlight] () => testUtils.db().seed(), ], teardown: [], } ``` ### Cleaning Up between tests While migrations run once globally, you need to clean up data between individual tests to prevent state from leaking. AdonisJS offers two approaches: global transactions and truncation. **Global transactions** wrap all database operations within a test inside a transaction, then roll back when the test completes. Nothing is actually persisted to the database, which can result in faster test execution. ```ts title="tests/functional/users.spec.ts" import { test } from '@japa/runner' import testUtils from '@adonisjs/core/services/test_utils' test.group('Users', (group) => { group.each.setup(() => testUtils.db().withGlobalTransaction()) test('can create a user', async () => { // Database changes here are automatically rolled back after the test }) }) ``` The `withGlobalTransaction()` method returns a cleanup function that Japa calls automatically after each test to roll back the transaction. **Truncation** clears all data from tables between tests. This approach actually deletes records rather than rolling back transactions. ```ts title="tests/functional/posts.spec.ts" import { test } from '@japa/runner' import testUtils from '@adonisjs/core/services/test_utils' test.group('Posts', (group) => { group.each.setup(() => testUtils.db().truncate()) test('can create a post', async () => { // Tables are truncated before each test }) }) ``` Global transactions are generally faster, especially when your database has many tables, since rolling back a transaction is less expensive than truncating every table. Choose the approach that best fits your testing needs. ## Filesystem state management For tests that create files, use the `@japa/file-system` plugin. This plugin provides a simple API for managing files and automatically cleans them up after each test. Install the plugin as a dev dependency. ```sh npm i -D @japa/file-system ``` Register the plugin in your test bootstrap file. ```ts title="tests/bootstrap.ts" import { fileSystem } from '@japa/file-system' export const plugins: Config['plugins'] = [ assert(), pluginAdonisJS(app), // [!code highlight] fileSystem(), ] ``` Access the `fs` object within your tests to create files. Any files created through this API are automatically deleted when the test completes. ```ts title="tests/functional/uploads.spec.ts" import { test } from '@japa/runner' test('can process an uploaded file', async ({ fs }) => { await fs.create('document.pdf', 'file contents') // Test your file processing logic // The file is automatically cleaned up after the test }) ``` Files are created in a temporary directory managed by the plugin. For more configuration options and advanced usage, see the [Japa file-system plugin documentation](https://japa.dev/docs/plugins/file-system). ## Redis state management For tests that interact with Redis, flush the test database between tests to ensure a clean state. Use a group setup hook to call `flushdb()` before each test. ```ts title="tests/functional/cache.spec.ts" import { test } from '@japa/runner' import redis from '@adonisjs/redis/services/main' test.group('Cache', (group) => { group.each.teardown(async () => { await redis.flushdb() }) test('can cache a value', async () => { // Redis is empty at the start of each test }) }) ``` The `flushdb()` command clears all keys in the currently selected Redis database without affecting other databases on the same Redis server. Make sure your test environment is configured to use a different Redis database number than development or production. ## Environment configuration Use the `.env.test` file to override environment variables specifically for your test environment. This file is automatically loaded when running tests. ```dotenv title=".env.test" DB_DATABASE=my_app_test REDIS_DB=1 ``` This ensures your tests run against isolated databases without risking your development or production data. --- # Test doubles This guide covers test doubles in AdonisJS applications. You will learn how to: - Use built-in fakes for Mail, Hash, Emitter, and Drive services - Swap container bindings to fake dependencies using `swap` and `useFake` - Freeze and travel through time when testing time-sensitive code - Integrate Sinon.js for additional stubbing and mocking needs ## Overview Test doubles replace real implementations with controlled alternatives during testing. They allow you to isolate code under test, avoid side effects like sending real emails, and verify that your code interacts correctly with its dependencies. AdonisJS takes a pragmatic approach to test doubles. For internal operations like database queries, we recommend hitting the real database rather than mocking query methods. Real database interactions catch issues that mocks would miss, such as constraint violations, incorrect query syntax, or migration problems. However, for external services like email providers, payment gateways, or third-party APIs, fakes prevent unwanted side effects and make tests faster and more reliable. The framework provides built-in fakes for common services that interact with external systems, along with container swaps for replacing your own dependencies. For edge cases not covered by these tools, you can integrate libraries like Sinon.js. ## Built-in fakes AdonisJS provides fake implementations for services that typically interact with external systems. Each fake intercepts calls to the real service and captures them for assertions. All built-in fakes support [Explicit Resource Management](https://github.com/tc39/proposal-explicit-resource-management) via the `using` keyword. When the variable goes out of scope (at the end of the test function), the fake is automatically restored. If you prefer manual control, you can still call `.restore()` directly or use the `cleanup` hook. ### Emitter fake The emitter fake prevents event listeners from executing while capturing emitted events for assertions. This is useful when testing code that emits events without triggering side effects like sending notifications or updating external systems. ```ts title="tests/functional/users/register.spec.ts" import { test } from '@japa/runner' import emitter from '@adonisjs/core/services/emitter' import { events } from '#generated/events' test.group('User registration', () => { test('emits registration event on signup', async ({ client }) => { /** * Fake the emitter to capture events without * executing listeners. The `using` keyword automatically * restores the emitter when the test ends. */ // [!code highlight] using fakeEmitter = emitter.fake() await client.post('/signup').form({ email: 'jane@example.com', password: 'secret123', }) /** * Assert the event was emitted */ fakeEmitter.assertEmitted(events.UserRegistered) }) }) ``` You can fake specific events while allowing others to execute normally by passing event names or classes to the `fake` method. ```ts // Fake only these events, let others execute normally emitter.fake([events.UserRegistered, events.OrderUpdated]) ``` The `EventBuffer` returned by `emitter.fake()` provides several assertion methods. | Method | Description | | ---------------------------------- | ------------------------------------------------------ | | `assertEmitted(event)` | Assert an event was emitted | | `assertNotEmitted(event)` | Assert an event was not emitted | | `assertEmittedCount(event, count)` | Assert an event was emitted a specific number of times | | `assertNoneEmitted()` | Assert no events were emitted | For conditional assertions, pass a callback to `assertEmitted` that receives the event data and returns `true` if the event matches your criteria. ```ts title="tests/functional/orders/update.spec.ts" fakeEmitter.assertEmitted(events.OrderUpdated, ({ data }) => { return data.order.id === orderId }) ``` See also: [Events](../digging_deeper/emitter.md) ### Hash fake The hash fake replaces the real hashing implementation with a fast alternative that performs no actual hashing. Password hashing algorithms like bcrypt and argon2 are intentionally slow for security, but this can significantly slow down test suites that create many users. ```ts title="tests/functional/users/list.spec.ts" import { test } from '@japa/runner' import hash from '@adonisjs/core/services/hash' import { UserFactory } from '#database/factories/user_factory' test.group('Users list', () => { test('paginates users correctly', async ({ client }) => { /** * Fake the hash service to make user creation instant. * Without this, creating 50 users with bcrypt takes ~5 seconds. * The `using` keyword automatically restores the real * implementation when the test ends. */ // [!code highlight] using _hash = hash.fake() await UserFactory.createMany(50) const response = await client.get('/users') response.assertStatus(200) }) }) ``` The fake stores plain text and compares strings directly. It should only be used in tests where password hashing is not the focus of what you're testing. See also: [Hashing](../security/hashing.md) ### Mail fake The mail fake intercepts all emails and captures them for assertions. This prevents your tests from sending real emails while allowing you to verify that the correct emails would be sent. ```ts title="tests/functional/users/register.spec.ts" import { test } from '@japa/runner' import mail from '@adonisjs/mail/services/main' import VerifyEmailNotification from '#mails/verify_email' test.group('User registration', () => { test('sends verification email on signup', async ({ client }) => { /** * Fake the mailer. The `using` keyword automatically * restores the real mailer when the test ends. */ // [!code highlight] using fake = mail.fake() await client.post('/register').form({ email: 'user@example.com', password: 'secret123' }) /** * Assert the email was sent with correct recipient and subject */ fake.mails.assertSent(VerifyEmailNotification, ({ message }) => { return message.hasTo('user@example.com').hasSubject('Please verify your email address') }) }) test('does not send reset email for unknown user', async ({ client }) => { // [!code highlight] using fake = mail.fake() await client.post('/forgot-password').form({ email: 'unknown@example.com' }) fake.mails.assertNotSent(PasswordResetNotification) }) }) ``` The `mails` object provides assertion methods for both sent and queued emails. | Method | Description | | -------------------------------- | ------------------------------------------ | | `assertSent(Mail, finder?)` | Assert an email class was sent | | `assertNotSent(Mail, finder?)` | Assert an email class was not sent | | `assertSentCount(count)` | Assert total number of emails sent | | `assertSentCount(Mail, count)` | Assert count for a specific email class | | `assertNoneSent()` | Assert no emails were sent | | `assertQueued(Mail, finder?)` | Assert an email was queued via `sendLater` | | `assertNotQueued(Mail, finder?)` | Assert an email was not queued | | `assertQueuedCount(count)` | Assert total number of queued emails | | `assertNoneQueued()` | Assert no emails were queued | You can also test mail classes in isolation by building them without sending. ```ts title="tests/unit/mails/verify_email.spec.ts" import { test } from '@japa/runner' import { UserFactory } from '#database/factories/user_factory' import VerifyEmailNotification from '#mails/verify_email' test.group('VerifyEmailNotification', () => { test('builds correct message', async () => { const user = await UserFactory.create() const email = new VerifyEmailNotification(user) /** * Build the message and render templates without sending */ await email.buildWithContents() email.message.assertTo(user.email) email.message.assertFrom('noreply@example.com') email.message.assertSubject('Please verify your email address') email.message.assertHtmlIncludes(`Hello ${user.name}`) }) }) ``` See also: [Mail](../digging_deeper/mail.md) ### Drive fake The drive fake replaces a disk with a local filesystem implementation. Files are written to `./tmp/drive-fakes` and automatically deleted when you restore the fake. ```ts title="tests/functional/users/update.spec.ts" import { test } from '@japa/runner' import drive from '@adonisjs/drive/services/main' import fileGenerator from '@poppinss/file-generator' import { UserFactory } from '#database/factories/user_factory' test.group('User avatar upload', () => { test('uploads avatar to storage', async ({ client }) => { /** * Fake the spaces disk to avoid uploading to real S3. * The `using` keyword automatically restores the real * disk when the test ends. */ // [!code highlight] using fakeDisk = drive.fake('spaces') const user = await UserFactory.create() /** * Generate a fake 1mb PNG file */ const { contents, mime, name } = await fileGenerator.generatePng('1mb') await client .put('/me') .file('avatar', contents, { filename: name, contentType: mime }) .loginAs(user) /** * Assert the file was stored */ fakeDisk.assertExists(user.avatar) }) }) ``` See also: [Drive](../digging_deeper/drive.md) ## Container swaps When using dependency injection, you can swap container bindings to replace services with fake implementations. This is useful for faking your own services, such as a payment gateway or external API client. The recommended approach is to create a dedicated fake implementation that extends or implements the same interface as the real service. ```ts title="app/services/payment_gateway.ts" export default class PaymentGateway { async charge(amount: number, token: string): Promise { /** * Real implementation that calls Stripe, Braintree, etc. */ } async refund(chargeId: string): Promise { /** * Real implementation */ } } ``` ```ts title="app/services/fake_payment_gateway.ts" import PaymentGateway from './payment_gateway.js' export default class FakePaymentGateway extends PaymentGateway { /** * Store charges for assertions */ charges: Array<{ amount: number; token: string }> = [] async charge(amount: number, token: string): Promise { this.charges.push({ amount, token }) return { id: 'fake_charge_123', status: 'succeeded', } } async refund(chargeId: string): Promise { return { id: 'fake_refund_123', status: 'succeeded', } } /** * Helper method to assert a charge was made */ assertCharged(amount: number) { const charge = this.charges.find((c) => c.amount === amount) if (!charge) { throw new Error(`Expected charge of ${amount} but none found`) } } } ``` ### Swapping bindings in tests The test context provides a `swap` method that replaces a container binding with a fake for the duration of the test. The original binding is automatically restored after the test completes, so you don't need to manage cleanup yourself. ```ts title="tests/functional/orders/checkout.spec.ts" import { test } from '@japa/runner' import PaymentGateway from '#services/payment_gateway' import FakePaymentGateway from '#services/fake_payment_gateway' test.group('Checkout', () => { test('charges the customer on checkout', async ({ client, swap }) => { /** * Swap the real payment gateway with the fake. * The original binding is restored when the test ends. */ const fakePayment = swap(PaymentGateway, new FakePaymentGateway()) await client.post('/checkout').json({ cartId: 'cart_123', paymentToken: 'tok_visa' }) fakePayment.assertCharged(9999) }) }) ``` The `swap` method accepts either a direct instance or a factory function. Use a factory function when you need a fresh instance each time the binding is resolved. ```ts title="tests/functional/orders/checkout.spec.ts" // Pass a factory function instead of an instance swap(PaymentGateway, () => new FakePaymentGateway()) ``` ### The `useFake` helper The `useFake` helper provides the same functionality as `swap` but as a standalone function. This is useful when you want to extract swap logic into reusable test helpers outside of the test callback. ```ts title="tests/helpers/fakes.ts" import { useFake } from '@japa/plugin-adonisjs/helpers' import PaymentGateway from '#services/payment_gateway' import FakePaymentGateway from '#services/fake_payment_gateway' /** * Reusable helper that swaps the payment gateway * and returns the fake for assertions */ export function useFakePaymentGateway() { return useFake(PaymentGateway, new FakePaymentGateway()) } ``` ```ts title="tests/functional/orders/checkout.spec.ts" import { test } from '@japa/runner' import { useFakePaymentGateway } from '#tests/helpers/fakes' test.group('Checkout', () => { test('charges the customer on checkout', async ({ client }) => { const fakePayment = useFakePaymentGateway() await client.post('/checkout').json({ cartId: 'cart_123', paymentToken: 'tok_visa' }) fakePayment.assertCharged(9999) }) }) ``` Like `swap`, `useFake` automatically restores the original binding when the test completes. Both `swap` and `useFake` can only be called within a running Japa test. ### Manual swap with `container.swap` You can also call `container.swap` and `container.restore` directly on the application container. This gives you full control over when the binding is restored, which can be useful in group-level setup hooks or when you need to restore a binding before the test ends. ```ts title="tests/functional/orders/checkout.spec.ts" import { test } from '@japa/runner' import app from '@adonisjs/core/services/app' import PaymentGateway from '#services/payment_gateway' import FakePaymentGateway from '#services/fake_payment_gateway' test.group('Checkout', () => { test('charges the customer on checkout', async ({ client, cleanup }) => { const fakePayment = new FakePaymentGateway() /** * Swap the binding and register cleanup manually */ app.container.swap(PaymentGateway, () => fakePayment) cleanup(() => app.container.restore(PaymentGateway)) await client.post('/checkout').json({ cartId: 'cart_123', paymentToken: 'tok_visa' }) fakePayment.assertCharged(9999) }) }) ``` See also: [Dependency injection](../concepts/dependency_injection.md) ## Time utilities Japa provides utilities for controlling time during tests. Both `freezeTime` and `timeTravel` mock `new Date()` and `Date.now()`, and automatically restore the real implementations after the test completes. ### Freezing time The `freezeTime` function locks time to a specific moment. This is useful when testing code that checks timestamps, such as token expiration. ```ts title="tests/functional/auth/token.spec.ts" import { test } from '@japa/runner' import { freezeTime } from '@japa/runner' import { UserFactory } from '#database/factories/user_factory' test.group('Token expiration', () => { test('rejects expired tokens', async ({ client }) => { const user = await UserFactory.create() /** * Create a token at the current time */ const token = await user.createToken() /** * Freeze time to 2 hours in the future, past the token's * 1-hour expiration window */ const futureDate = new Date(Date.now() + 2 * 60 * 60 * 1000) freezeTime(futureDate) const response = await client.get('/protected').header('Authorization', `Bearer ${token.value}`) response.assertStatus(401) }) }) ``` ### Traveling through time The `timeTravel` function moves time forward by a duration. You can pass a human-readable string expression or a `Date` object. ```ts title="tests/functional/subscriptions/expiry.spec.ts" import { test } from '@japa/runner' import { timeTravel } from '@japa/runner' import { UserFactory } from '#database/factories/user_factory' test.group('Subscription expiry', () => { test('marks subscription as expired after 30 days', async ({ client }) => { const user = await UserFactory.with('subscription', 1, (s) => s.merge({ startsAt: new Date() }) ).create() /** * Travel 31 days into the future */ timeTravel('31 days') const response = await client.get('/subscription').loginAs(user) response.assertStatus(200) response.assertBodyContains({ status: 'expired' }) }) }) ``` Both utilities only mock the `Date` object. They do not affect timers like `setTimeout` or `setInterval`. ## Sinon.js For stubbing and mocking needs not covered by the built-in fakes, you can use [Sinon.js](https://sinonjs.org/). Install it as a development dependency. ```sh npm install -D sinon @types/sinon ``` Sinon provides stubs, spies, and mocks for fine-grained control over function behavior. Always call `sinon.restore()` after tests to clean up. ```ts title="tests/functional/reports/generate.spec.ts" import { test } from '@japa/runner' import sinon from 'sinon' import ReportService from '#services/report_service' test.group('Report generation', (group) => { group.each.teardown(() => { sinon.restore() }) test('retries on temporary failure', async ({ client }) => { const stub = sinon.stub(ReportService.prototype, 'generate') stub.onFirstCall().rejects(new Error('Temporary failure')) stub.onSecondCall().resolves({ id: 'report_123' }) const response = await client.post('/reports') response.assertStatus(200) sinon.assert.calledTwice(stub) }) }) ``` For comprehensive documentation on stubs, spies, mocks, and fake timers, see the [Sinon.js documentation](https://sinonjs.org/releases/latest/). --- # Application This guide covers the Application class in AdonisJS. You will learn how to: - Access the runtime environment (web, console, repl, test) - Check the Node.js environment and application state - Listen for process signals and notify parent processes - Generate absolute paths to project directories and files - Use generators for consistent naming conventions ## Overview The [Application](https://github.com/adonisjs/application/blob/9.x/src/application.ts) class handles the heavy lifting of wiring together an AdonisJS application. It manages the application lifecycle, provides access to environment information, tracks the current state, and offers helper methods for generating paths to various project directories. You access the Application instance through the `app` service, which is available throughout your application. See also: [Application lifecycle](../guides/concepts/application_lifecycle.md) ```ts title="app/services/some_service.ts" import app from '@adonisjs/core/services/app' ``` ## Environment The environment refers to the runtime context in which your application is running. AdonisJS recognizes four distinct environments: | Environment | Description | |-------------|-------------| | `web` | The process started for the HTTP server | | `console` | Ace commands (except the REPL command) | | `repl` | The process started using `node ace repl` | | `test` | The process started using `node ace test` | You can access the current environment using the `getEnvironment` method. ```ts title="app/services/some_service.ts" import app from '@adonisjs/core/services/app' console.log(app.getEnvironment()) ``` ### Switching the environment You can switch the application environment before it has been booted. This is useful when a command needs to run in a different context than it was started in. For example, the `node ace repl` command starts in the `console` environment but switches to `repl` before presenting the prompt. ```ts title="commands/my_command.ts" import app from '@adonisjs/core/services/app' if (!app.isBooted) { app.setEnvironment('repl') } ``` ## Node environment The `nodeEnvironment` property provides access to the Node.js environment, derived from the `NODE_ENV` environment variable. AdonisJS normalizes common variations to ensure consistency across different deployment configurations. ```ts title="app/services/some_service.ts" import app from '@adonisjs/core/services/app' console.log(app.nodeEnvironment) ``` | NODE_ENV | Normalized to | |----------|---------------| | dev | development | | develop | development | | stage | staging | | prod | production | | testing | test | ### Shorthand properties Instead of comparing strings, you can use these boolean properties to check the current environment. ```ts title="app/services/some_service.ts" import app from '@adonisjs/core/services/app' /** * Check if running in production */ app.inProduction app.nodeEnvironment === 'production' /** * Check if running in development */ app.inDev app.nodeEnvironment === 'development' /** * Check if running tests */ app.inTest app.nodeEnvironment === 'test' ``` ## State The state represents where the application is in its lifecycle. The features you can access depend on the current state—for example, you cannot access [container bindings](../guides/concepts/dependency_injection.md#bindings) or [container services](../guides/concepts/container_services.md) until the app reaches the `booted` state. | State | Description | |-------|-------------| | `created` | Default state when Application instance is created | | `initiated` | Environment variables parsed and `adonisrc.ts` processed | | `booted` | Service providers registered and booted | | `ready` | Application ready to handle requests (meaning varies by environment) | | `terminated` | Application terminated and process will exit shortly | ```ts title="app/services/some_service.ts" import app from '@adonisjs/core/services/app' console.log(app.getState()) ``` ### Shorthand properties ```ts title="app/services/some_service.ts" import app from '@adonisjs/core/services/app' /** * True when state is past 'initiated' */ app.isBooted /** * True when state is 'ready' */ app.isReady /** * True when gracefully attempting to terminate */ app.isTerminating /** * True when state is 'terminated' */ app.isTerminated ``` ## Listening for process signals You can listen for [POSIX signals](https://man7.org/linux/man-pages/man7/signal.7.html) using the `app.listen` or `app.listenOnce` methods. These register listeners with the Node.js `process` object. ```ts title="start/events.ts" import app from '@adonisjs/core/services/app' app.listen('SIGTERM', () => { // Handle SIGTERM }) app.listenOnce('SIGTERM', () => { // Handle SIGTERM once }) ``` ### Conditional listeners Use `listenIf` or `listenOnceIf` to register listeners only when a condition is met. The listener is registered only when the first argument is truthy. ```ts title="start/events.ts" import app from '@adonisjs/core/services/app' /** * Only listen for SIGINT when running under pm2 */ app.listenIf(app.managedByPm2, 'SIGINT', () => { // Handle SIGINT in pm2 }) app.listenOnceIf(app.managedByPm2, 'SIGINT', () => { // Handle SIGINT once in pm2 }) ``` ## Notifying parent process When your application runs as a child process, you can send messages to the parent using the `app.notify` method. This wraps the `process.send` method. ```ts title="start/events.ts" import app from '@adonisjs/core/services/app' app.notify('ready') app.notify({ isReady: true, port: 3333, host: 'localhost' }) ``` ## Making paths to project files The Application class provides helper methods that generate absolute paths to files and directories within your project. These helpers respect the directory structure configured in your `adonisrc.ts` file, ensuring paths remain correct even if you customize directory locations. ### makePath Returns an absolute path to a file or directory within the project root. ```ts title="app/services/some_service.ts" import app from '@adonisjs/core/services/app' app.makePath('app/middleware/auth.ts') // /project_root/app/middleware/auth.ts ``` ### makeURL Returns a file URL to a file or directory within the project root. This is useful when dynamically importing files. ```ts title="app/services/test_runner.ts" import app from '@adonisjs/core/services/app' const files = [ './tests/welcome.spec.ts', './tests/maths.spec.ts' ] await Promise.all(files.map((file) => { return import(app.makeURL(file).href) })) ``` ### tmpPath Returns a path to a file inside the `tmp` directory within the project root. ```ts title="app/services/some_service.ts" app.tmpPath('logs/mail.txt') // /project_root/tmp/logs/mail.txt app.tmpPath() // /project_root/tmp ``` ### configPath ```ts title="app/services/some_service.ts" app.configPath('shield.ts') // /project_root/config/shield.ts app.configPath() // /project_root/config ``` ### publicPath ```ts title="app/services/some_service.ts" app.publicPath('style.css') // /project_root/public/style.css app.publicPath() // /project_root/public ``` ### viewsPath ```ts title="app/services/some_service.ts" app.viewsPath('welcome.edge') // /project_root/resources/views/welcome.edge app.viewsPath() // /project_root/resources/views ``` ### languageFilesPath ```ts title="app/services/some_service.ts" app.languageFilesPath('en/messages.json') // /project_root/resources/lang/en/messages.json app.languageFilesPath() // /project_root/resources/lang ``` ### httpControllersPath ```ts title="app/services/some_service.ts" app.httpControllersPath('users_controller.ts') // /project_root/app/controllers/users_controller.ts app.httpControllersPath() // /project_root/app/controllers ``` ### modelsPath ```ts title="app/services/some_service.ts" app.modelsPath('user.ts') // /project_root/app/models/user.ts app.modelsPath() // /project_root/app/models ``` ### servicesPath ```ts title="app/services/some_service.ts" app.servicesPath('user_service.ts') // /project_root/app/services/user_service.ts app.servicesPath() // /project_root/app/services ``` ### middlewarePath ```ts title="app/services/some_service.ts" app.middlewarePath('auth.ts') // /project_root/app/middleware/auth.ts app.middlewarePath() // /project_root/app/middleware ``` ### validatorsPath ```ts title="app/services/some_service.ts" app.validatorsPath('create_user.ts') // /project_root/app/validators/create_user.ts app.validatorsPath() // /project_root/app/validators ``` ### policiesPath ```ts title="app/services/some_service.ts" app.policiesPath('post_policy.ts') // /project_root/app/policies/post_policy.ts app.policiesPath() // /project_root/app/policies ``` ### exceptionsPath ```ts title="app/services/some_service.ts" app.exceptionsPath('handler.ts') // /project_root/app/exceptions/handler.ts app.exceptionsPath() // /project_root/app/exceptions ``` ### transformersPath ```ts title="app/services/some_service.ts" app.transformersPath('user.ts') // /project_root/app/transformers/user.ts app.transformersPath() // /project_root/app/transformers ``` ### eventsPath ```ts title="app/services/some_service.ts" app.eventsPath('user_created.ts') // /project_root/app/events/user_created.ts app.eventsPath() // /project_root/app/events ``` ### listenersPath ```ts title="app/services/some_service.ts" app.listenersPath('send_invoice.ts') // /project_root/app/listeners/send_invoice.ts app.listenersPath() // /project_root/app/listeners ``` ### mailsPath ```ts title="app/services/some_service.ts" app.mailsPath('verify_email.ts') // /project_root/app/mails/verify_email.ts app.mailsPath() // /project_root/app/mails ``` ### migrationsPath ```ts title="app/services/some_service.ts" app.migrationsPath('create_users_table.ts') // /project_root/database/migrations/create_users_table.ts app.migrationsPath() // /project_root/database/migrations ``` ### seedersPath ```ts title="app/services/some_service.ts" app.seedersPath('user_seeder.ts') // /project_root/database/seeders/user_seeder.ts app.seedersPath() // /project_root/database/seeders ``` ### factoriesPath ```ts title="app/services/some_service.ts" app.factoriesPath('user_factory.ts') // /project_root/database/factories/user_factory.ts app.factoriesPath() // /project_root/database/factories ``` ### generatedServerPath Returns a path to a file inside the `.adonisjs/server` directory, which contains server-side barrel files like controller imports. ```ts title="app/services/some_service.ts" app.generatedServerPath('controllers.ts') // /project_root/.adonisjs/server/controllers.ts app.generatedServerPath() // /project_root/.adonisjs/server ``` ### generatedClientPath Returns a path to a file inside the `.adonisjs/client` directory, which contains client-side generated files. ```ts title="app/services/some_service.ts" app.generatedClientPath('manifest.json') // /project_root/.adonisjs/client/manifest.json app.generatedClientPath() // /project_root/.adonisjs/client ``` ### startPath ```ts title="app/services/some_service.ts" app.startPath('routes.ts') // /project_root/start/routes.ts app.startPath() // /project_root/start ``` ### providersPath ```ts title="app/services/some_service.ts" app.providersPath('app_provider.ts') // /project_root/providers/app_provider.ts app.providersPath() // /project_root/providers ``` ### commandsPath ```ts title="app/services/some_service.ts" app.commandsPath('greet.ts') // /project_root/commands/greet.ts app.commandsPath() // /project_root/commands ``` ## Generators Generators create consistent class names and file names for different entities in your application. They ensure naming conventions are followed when creating new files programmatically. ```ts title="commands/make_resource.ts" import app from '@adonisjs/core/services/app' app.generators.controllerFileName('user') // users_controller.ts app.generators.controllerName('user') // UsersController ``` See the [`generators.ts` source code](https://github.com/adonisjs/application/blob/9.x/src/generators.ts) for the complete list of available generators. --- # AdonisRC file This guide covers the `adonisrc.ts` configuration file. You will learn how to: - Register service providers and preload files - Configure directory paths for scaffolding commands - Define command aliases for frequently used Ace commands - Set up assembler hooks for build-time code generation - Specify meta files to include in production builds - Configure test suites and runner options ## Overview The `adonisrc.ts` file serves as the central configuration for your AdonisJS workspace. It controls how the framework boots, where scaffolding commands place generated files, which providers to load, and how the build process behaves. This file is processed by multiple tools beyond your main application, including the Ace CLI, the Assembler (which handles the dev server and production builds), and various code generators. Because of this broad usage, the file must remain environment-agnostic and free of application-specific logic. The file contains the minimum required configuration to run your application. You can view the complete expanded configuration, including all defaults, by running the `node ace inspect:rcfile` command. ```sh node ace inspect:rcfile ``` You can access the parsed RCFile contents programmatically using the `app` service. ```ts title="app/services/some_service.ts" import app from '@adonisjs/core/services/app' console.log(app.rcFile) ``` ## directories The `directories` object maps logical directory names to their filesystem paths. Scaffolding commands use these mappings to determine where to place generated files. If you rename directories in your project structure, update the corresponding paths here so that commands like `node ace make:controller` continue to work correctly. ```ts title="adonisrc.ts" { directories: { config: 'config', commands: 'commands', contracts: 'contracts', public: 'public', providers: 'providers', languageFiles: 'resources/lang', migrations: 'database/migrations', seeders: 'database/seeders', factories: 'database/factories', views: 'resources/views', start: 'start', tmp: 'tmp', tests: 'tests', httpControllers: 'app/controllers', models: 'app/models', services: 'app/services', exceptions: 'app/exceptions', mailers: 'app/mailers', mails: 'app/mails', middleware: 'app/middleware', policies: 'app/policies', validators: 'app/validators', events: 'app/events', listeners: 'app/listeners', transformers: 'app/transformers', stubs: 'stubs', generatedClient: '.adonisjs/client', generatedServer: '.adonisjs/server', } } ``` ## preloads The `preloads` array specifies files to import during application boot. These files are imported immediately after service providers have been registered and booted, making them ideal for setup code that needs access to the container but should run before the application starts handling requests. You can register a preload file to run in all environments or restrict it to specific ones. | Environment | Description | |-------------|-------------| | `web` | The HTTP server process | | `console` | Ace commands (except `repl`) | | `repl` | The interactive REPL session | | `test` | The test runner process | The simplest form registers a file to run in all environments. ```ts title="adonisrc.ts" { preloads: [ () => import('./start/view.js') ] } ``` To restrict a preload file to specific environments, use the object form with an `environment` array. ```ts title="adonisrc.ts" { preloads: [ { file: () => import('./start/view.js'), environment: ['web', 'console', 'test'] }, ] } ``` :::note You can create and register a preload file using the `node ace make:preload` command. ::: ## providers The `providers` array lists [service providers](../guides/concepts/service_providers.md) to load during application boot. Providers are loaded in the order they appear in the array, which matters when providers depend on each other. Like preload files, providers can be registered for all environments or restricted to specific ones using the same environment values: `web`, `console`, `repl`, and `test`. ```ts title="adonisrc.ts" { providers: [ () => import('@adonisjs/core/providers/app_provider'), () => import('@adonisjs/core/providers/http_provider'), () => import('@adonisjs/core/providers/hash_provider'), () => import('./providers/app_provider.js'), ] } ``` To load a provider only in specific environments, use the object form. ```ts title="adonisrc.ts" { providers: [ () => import('@adonisjs/core/providers/app_provider'), () => import('@adonisjs/core/providers/hash_provider'), { file: () => import('@adonisjs/core/providers/http_provider'), environment: ['web'] }, { file: () => import('./providers/app_provider.js'), environment: ['web', 'console', 'test'] }, ] } ``` See also: [Service providers](../guides/concepts/service_providers.md) ## commands The `commands` array registers Ace commands from installed packages. Your application's own commands (in the `commands` directory) are discovered automatically and do not need to be registered here. ```ts title="adonisrc.ts" { commands: [ () => import('@adonisjs/core/commands'), () => import('@adonisjs/lucid/commands') ] } ``` See also: [Creating Ace commands](../guides/ace/creating_commands.md) ## commandsAliases The `commandsAliases` object creates shortcuts for frequently used commands. This is useful for commands with long names or commands you run often. ```ts title="adonisrc.ts" { commandsAliases: { migrate: 'migration:run' } } ``` You can define multiple aliases pointing to the same command. ```ts title="adonisrc.ts" { commandsAliases: { migrate: 'migration:run', up: 'migration:run' } } ``` See also: [Creating command aliases](../guides/ace/introduction.md#creating-command-aliases) ## hooks The `hooks` object registers callbacks that run at specific points during the Assembler lifecycle. The Assembler is the tool responsible for running the dev server, creating production builds, running tests, and performing code generation. Hooks can be defined inline or as lazily-imported modules. They run in a separate process from your AdonisJS application and do not have access to the IoC container or framework services. ```ts title="adonisrc.ts" import { defineConfig } from '@adonisjs/core/app' import { indexEntities } from '@adonisjs/core/app' export default defineConfig({ hooks: { init: [indexEntities()], buildStarting: [() => import('@adonisjs/vite/build_hook')], }, }) ``` The `indexEntities()` hook generates barrel files for controllers, events, and listeners, enabling lazy-loading and type-safe references. Package hooks like `@adonisjs/vite/build_hook` handle build-time asset compilation. See also: [Assembler hooks](../guides/concepts/assembler_hooks.md) for a complete reference of available hooks and how to create custom hooks for code generation. ## metaFiles The `metaFiles` array specifies non-TypeScript files to copy into the `build` folder when creating a production build. This includes templates, language files, and other assets your application needs at runtime. Each entry accepts a glob pattern and an optional `reloadServer` flag that triggers a dev server restart when matching files change. | Property | Description | |----------|-------------| | `pattern` | A [glob pattern](https://nodejs.org/api/fs.html#fspromisesglobpattern-options) to match files | | `reloadServer` | Whether to restart the dev server when these files change | ```ts title="adonisrc.ts" { metaFiles: [ { pattern: 'public/**', reloadServer: false }, { pattern: 'resources/views/**/*.edge', reloadServer: false } ] } ``` ## tests The `tests` object configures the test runner, including global timeout settings and test suite definitions. ```ts title="adonisrc.ts" { tests: { timeout: 2000, forceExit: false, suites: [ { name: 'functional', files: ['tests/functional/**/*.spec.ts'], timeout: 30000 } ] } } ``` | Property | Description | |----------|-------------| | `timeout` | Default timeout in milliseconds for all tests | | `forceExit` | Whether to force-exit the process after tests complete. Graceful exit is recommended | | `suites[].name` | A unique identifier for the test suite | | `suites[].files` | Glob patterns to discover test files | | `suites[].timeout` | Suite-specific timeout that overrides the global default | See also: [Introduction to testing](../guides/testing/introduction.md) --- # Commands reference In this guide, we cover the usage of all the commands shipped with the framework core and the official packages. You may also view the commands help using the `node ace list` command or the `node ace --help` command. ```sh node ace list ``` :::media{alt="The output of the help screen is formatted as per the http://docopt.org standard"} ![](../guides/ace/node_ace_list.png) ::: ## serve The `serve` uses the [@adonisjs/assembler](https://github.com/adonisjs/assembler?tab=readme-ov-file#dev-server) package to start the AdonisJS application in development environment. You can optionally watch for file changes and restart the HTTP server on every file change. ```sh node ace serve --hmr ``` The `serve` command starts the development server (via the `bin/server.ts` file) as a child process. If you want to pass [node arguments](https://nodejs.org/api/cli.html#options) to the child process, you can define them before the command name. ```sh node ace --no-warnings --inspect serve --hmr ``` Following is the list of available options you can pass to the `serve` command. Alternatively, use the `--help` flag to view the command's help. ::::options :::option{name="--hmr"} Watch the filesystem and reload the server in HMR mode. ::: :::option{name="--watch"} Watch the filesystem and always restart the process on file change. ::: :::option{name="--poll"} Use polling to detect filesystem changes. You might want to use polling when using a Docker container for development. ::: :::option{name="--clear | --no-clear"} Clear the terminal after every file change and before displaying the new logs. Use the `--no-clear` flag to retain old logs. ::: :::: ## build The `build` command uses the [@adonisjs/assembler](https://github.com/adonisjs/assembler?tab=readme-ov-file#bundler) package to create the production build of your AdonisJS application. The following steps are performed to generate the build. See also: [Creating the production build](../start/deployment.md#creating-the-production-build). ```sh node ace build ``` Following is the list of available options you can pass to the `build` command. Alternatively, use the `--help` flag to view the command's help. ::::options :::option{name="--ignore-ts-errors"} The build command terminates the build process when your project has TypeScript errors. However, you can ignore those errors and finish the build using the `--ignore-ts-errors` flag. ::: :::option{name="--package-manager"} The build command copies the `package.json` file alongside the lock file of the package manager your application is using. We detect the package manager using the `@antfu/install-pkg` package. However, you can turn off detection by explicitly providing the package manager's name. ::: :::: ## add The `add` command combines the `npm install ` and `node ace configure` commands. So, instead of running two separate commands, you can install and configure the package in one go using the `add` command. The `add` command will automatically detect the package manager used by your application and use that to install the package. However, you can always opt for a specific package manager using the `--package-manager` CLI flag. ```sh # Install and configure the @adonisjs/lucid package node ace add @adonisjs/lucid # Install the package as a development dependency and configure it node ace add my-dev-package --dev ``` If the package can be configured using flags, you can pass them directly to the `add` command. Every unknown flag will be passed down to the `configure` command. ```sh node ace add @adonisjs/lucid --db=sqlite ```
--verbose
Enable verbose mode to display the package installation and configuration logs.
--force
Passed down to the `configure` command. Force overwrite files when configuring the package. See the `configure` command for more information.
--package-manager
Define the package manager to use for installing the package. The value must be `npm`, `pnpm`, `bun` or `yarn`.
--dev
Install the package as a development dependency.
## configure Configure a package after it has been installed. The command accepts the package name as the first argument. ```sh node ace configure @adonisjs/lucid ```
--verbose
Enable verbose mode to display the package installation logs.
--force
The stubs system of AdonisJS does not overwrite existing files. For example, if you configure the `@adonisjs/lucid` package and your application already has a `config/database.ts` file, the configure process will not overwrite the existing config file. However, you can force overwrite files using the `--force` flag.
## eject Eject stubs from a given package to your application `stubs` directory. In the following example, we copy the `make/controller` stubs to our application for modification. See also: [Customizing stubs](../guides/concepts/scaffolding.md#ejecting-stubs) ```sh # Copy stub from @adonisjs/core package node ace eject make/controller # Copy stub from @adonisjs/bouncer package node ace eject make/policy --pkg=@adonisjs/bouncer ``` ## generate\:key Generate a cryptographically secure random key and write to the `.env` file as the `APP_KEY` environment variable. See also: [App key](../guides/security/encryption.md) ```sh node ace generate:key ```
--show
Display the key on the terminal instead of writing it to the `.env` file. By default, the key is written to the env file.
--force
The `generate:key` command does not write the key to the `.env` file when running your application in production. However, you can use the `--force` flag to override this behavior.
## make\:controller Create a new HTTP controller class. Controllers are created inside the `app/controllers` directory and use the following naming conventions. - Form: `plural` - Suffix: `controller` - Class name example: `UsersController` - File name example: `users_controller.ts` ```sh node ace make:controller users ``` You also generate a controller with custom action names, as shown in the following example. ```sh # Generates controller with "index", "show", and "store" methods node ace make:controller users index show store ```
--singular
Force the controller name to be in singular form.
--resource
Generate a controller with methods to perform CRUD operations on a resource.
--api
The `--api` flag is similar to the `--resource` flag. However, it does not define the `create` and the `edit` methods since they are used to display forms.
## make\:middleware Create a new middleware for HTTP requests. Middleware are stored inside the `app/middleware` directory and uses the following naming conventions. - Form: `singular` - Suffix: `middleware` - Class name example: `BodyParserMiddleware` - File name example: `body_parser_middleware.ts` ```sh node ace make:middleware bodyparser ```
--stack
Skip the [middleware stack](../guides/basics/middleware.md#middleware-stacks) selection prompt by defining the stack explicitly. The value must be `server`, `named`, or `router`. ```sh node ace make:middleware bodyparser --stack=router ```
## make\:event Create a new event class. Events are stored inside the `app/events` directory and use the following naming conventions. - Form: `NA` - Suffix: `NA` - Class name example: `OrderShipped` - File name example: `order_shipped.ts` - Recommendation: You must name your events around the lifecycle of an action. For example: `MailSending`, `MailSent`, `RequestCompleted`, and so on. ```sh node ace make:event orderShipped ``` ## make\:validator Create a new VineJS validator file. The validators are stored inside the `app/validators` directory, and each file may export multiple validators. - Form: `singular` - Suffix: `NA` - File name example: `user.ts` - Recommendation: You must create validator files around the resources of your application. ```sh # A validator for managing a user node ace make:validator user # A validator for managing a post node ace make:validator post ```
--resource
Create a validator file with pre-defined validators for `create` and `update` actions. ```sh node ace make:validator post --resource ```
## make\:listener Create a new event listener class. The listener classes are stored inside the `app/listeners` directory and use the following naming conventions. - Form: `NA` - Suffix: `NA` - Class name example: `SendShipmentNotification` - File name example: `send_shipment_notification.ts` - Recommendation: The event listeners must be named after the action they perform. For example, a listener that sends the shipment notification email should be called `SendShipmentNotification`. ```sh node ace make:listener sendShipmentNotification ```
--event
Generate an event class alongside the event listener. ```sh node ace make:listener sendShipmentNotification --event=shipment_received ```
## make\:service Create a new service class. Service classes are stored inside the `app/services` directory and use the following naming conventions. :::note A service has no pre-defined meaning, and you can use it to extract the business logic inside your application. For example, if your application generates a lot of PDFs, you may create a service called `PdfGeneratorService` and reuse it in multiple places. ::: - Form: `singular` - Suffix: `service` - Class name example: `InvoiceService` - File name example: `invoice_service.ts` ```sh node ace make:service invoice ``` ## make\:exception Create a new [custom exception class](../guides/basics/exception_handling.md#custom-exceptions). Exceptions are stored inside the `app/exceptions` directory. - Form: `NA` - Suffix: `exception` - Class name example: `CommandValidationException` - File name example: `command_validation_exception.ts` ```sh node ace make:exception commandValidation ``` ## make\:command Create a new Ace command. By default, the commands are stored inside the `commands` directory at the root of your application. Commands from this directory are imported automatically by AdonisJS when you try to execute any Ace command. You may prefix the filename with an `_` to store additional files that are not Ace commands in this directory. - Form: `NA` - Suffix: `NA` - Class name example: `ListRoutes` - File name example: `list_routes.ts` - Recommendation: Commands must be named after the action they perform. For example, `ListRoutes`, `MakeController`, and `Build`. ```sh node ace make:command listRoutes ``` ## make\:view Create a new Edge.js template file. The templates are created inside the `resources/views` directory. - Form: `NA` - Suffix: `NA` - File name example: `posts/view.edge` - Recommendation: You must group templates for a resource inside a subdirectory. For example: `posts/list.edge`, `posts/create.edge`, and so on. ```sh node ace make:view posts/create node ace make:view posts/list ``` ## make\:provider Create a [service provider file](../guides/concepts/service_providers.md). Providers are stored inside the `providers` directory at the root of your application and use the following naming conventions. - Form: `singular` - Suffix: `provider` - Class name example: `AppProvider` - File name example: `app_provider.ts` ```sh node ace make:provider app ```
--environments
Define environments in which the provider should get imported. [Learn more about app environments](./application.md#environment) ```sh node ace make:provider app -e=web -e=console ```
## make\:preload Create a new [preload file](./adonisrc_file.md#preloads). Preload files are stored inside the `start` directory. ```sh node ace make:preload view ```
--environments
Define environments in which the preload file should get imported. [Learn more about app environments](./application.md#environment) ```sh node ace make:preload view app -e=web -e=console ```
## make\:test Create a new test file inside the `tests/` directory. - Form: NA - Suffix: `.spec` - File name example: `posts/list.spec.ts`, `posts/update.spec.ts` ```sh node ace make:test --suite=unit ```
--suite
Define the suite for which you want to create the test file. Otherwise, the command will display a prompt for suite selection.
## make\:mail Create a new mail class inside the `app/mails` directory. The mail classes are suffixed with the `Notification` keyword. However, you may define a custom suffix using the `--intent` CLI flag. - Form: NA - Suffix: `Intent` - Class name example: ShipmentNotification - File name example: shipment_notification.ts ```sh node ace make:mail shipment # ./app/mails/shipment_notification.ts ```
--intent
Define a custom intent for the mail. ```sh node ace make:mail shipment --intent=confirmation # ./app/mails/shipment_confirmation.ts node ace make:mail storage --intent=warning # ./app/mails/storage_warning.ts ```
## make\:policy Create a new Bouncer policy class. The policies are stored inside the `app/policies` folder and use the following naming conventions. - Form: `singular` - Suffix: `policy` - Class name example: `PostPolicy` - File name example: `post_policy.ts` ```sh node ace make:policy post ``` ## inspect\:rcfile View the contents of the `adonisrc.ts` file after merging the defaults. You may use this command to inspect the available configuration options and override them per your application requirements. See also: [AdonisRC file](./adonisrc_file.md) ```sh node ace inspect:rcfile ``` ## list\:routes View list of routes registered by your application. This command will boot your AdonisJS application in the `console` environment. ```sh node ace list:routes ``` Also, you can see the routes list from the VSCode activity bar if you are using our [official VSCode extension](https://marketplace.visualstudio.com/items?itemName=jripouteau.adonis-vscode-extension). :::media ![](../guides/basics/vscode_routes_list.png) :::
--json
View routes as a JSON string. The output will be an array of object.
--table
View routes inside a CLI table. By default, we display routes inside a compact, pretty list.
--middleware
Filter routes list and include the ones using the mentioned middleware. You may use the `*` keyword to include routes using one or more middleware.
--ignore-middleware
Filter routes list and include the ones NOT using the mentioned middleware. You may use the `*` keyword to include routes that do not use any middleware.
## env\:add The `env:add` command allows you to add a new environment variables to the `.env`, `.env.example` files and will also define the validation rules in the `start/env.ts` file. You can just run the command and it will prompt you for the variable name, value, and validation rules. Or you can pass them as arguments. ```sh # Will prompt for the variable name, value, and validation rules node ace env:add # Define the variable name, value, and validation rule node ace env:add MY_VARIABLE value --type=string ```
--type
Define the type of the environment variable. The value must be one of the following: `string`, `boolean`, `number`, `enum`.
--enum-values
Define the allowed values for the environment variable when the type is `enum`. ```sh node ace env:add MY_VARIABLE foo --type=enum --enum-values=foo --enum-values=bar ```
--- # Edge helpers and tags In this guide, we will learn about the **helpers and the tags** contributed to Edge by the AdonisJS official packages. The helpers shipped with Edge are not covered in this guide and must reference [Edge](https://edgejs.dev/docs/helpers) documentation for the same. ## request Reference to the instance of ongoing [HTTP request](../guides/basics/request.md). The property is only available when a template is rendered using the `ctx.view.render` method. ```edge {{ request.url() }} {{ request.input('signature') }} ``` ## route/signedRoute Helper functions to create URL for a route using the [URL builder](../guides/basics/routing.md#url-builder). Unlike the URL builder, the view helpers do not have a fluent API and accept the following parameters.
Position Description
1st The route identifier or the route pattern
2nd Route params are defined as an array or an object.
3rd

The options object with the following properties.

  • qs: Define query string parameters as an object.
  • domain: Search for routes under a specific domain.
  • prefixUrl: Prefix a URL to the output.
  • disableRouteLookup: Enable/disable routes lookup.
```edge View post ``` ```edge Unsubscribe ``` ## app Reference to the [Application instance](./application.md). ```edge {{ app.getEnvironment() }} ``` ## config A helper function to reference configuration values inside Edge templates. You may use the `config.has` method to check if the value for a key exists. ```edge @if(config.has('app.appUrl')) Home @else Home @end ``` ## session A read-only copy of the [session object](../guides/basics/session.md#reading-and-writing-data). You cannot mutate session data within Edge templates. The `session` property is only available when the template is rendered using the `ctx.view.render` method. ```edge Post views: {{ session.get(`post.${post.id}.visits`) }} ``` ## flashMessages A read-only copy of [session flash messages](../guides/basics/session.md#flash-messages). The `flashMessages` property is only available when the template is rendered using the `ctx.view.render` method. ```edge @if(flashMessages.has('inputErrorsBag.title'))

{{ flashMessages.get('inputErrorsBag.title') }}

@end @if(flashMessages.has('notification'))
{{ flashMessages.get('notification').message }}
@end ``` ## old The `old` method is a shorthand for the `flashMessages.get` method. ```edge ``` ## t The `t` method is contributed by the `@adonisjs/i18n` package to display translations using the [i18n class](../guides/digging_deeper/i18n.md#resolving-translations). The method accepts the translation key identifier, message data and a fallback message as the parameters. ```edge

{{ t('messages.greeting') }}

``` ## i18n Reference to an instance of the I18n class configured using the application's default locale. However, the [`DetectUserLocaleMiddleware`](../guides/digging_deeper/i18n.md#detecting-user-locale-during-an-http-request) overrides this property with an instance created for the current HTTP request locale. ```edge {{ i18n.formatCurrency(200, { currency: 'USD' }) }} ``` ## auth Reference to the [ctx.auth](../guides/basics/http_context.md#http-context-properties) property shared by the [InitializeAuthMiddleware](https://github.com/adonisjs/auth/blob/10.x/src/middleware/initialize_auth_middleware.ts#L19-L48). You may use this property to access information about the logged-in user. ```edge @if(auth.isAuthenticated)

{{ auth.user.email }}

@end ``` If you are displaying the logged-in user info on a public page (not protected by the auth middleware), then you may want to first silently check if the user is logged-in or not. ```edge {{-- Check if user is logged-in --}} @eval(await auth.use('web').check()) @if(auth.use('web').isAuthenticated)

{{ auth.use('web').user.email }}

@end ``` ## asset Resolve the URL of an asset processed by Vite. Learn more about [referencing assets inside Edge templates](../guides/frontend/vite.md#referencing-assets-inside-edge-templates). ```edge ``` ## embedImage / embedImageData The `embedImage` and the `embedImageData` helpers are added by the [mail](..//guides/digging_deeper/mail.md#embedding-images) package and are only available when rendering a template to send an email. ```edge ``` ## @flashMessage The `@flashMessage` tag provides a better DX for reading flash messages for a given key conditionally. :::caption{for="error"} **Instead of writing conditionals** ::: ```edge @if(flashMessages.has('notification'))
{{ flashMessages.get('notification').message }}
@end ``` :::caption{for="success"} **You may prefer using the tag** ::: ```edge @flashMessage('notification')
{{ $message.message }}
@end ``` ## @error The `@error` tag provides a better DX for reading error messages stored inside the `errorsBag` key in `flashMessages`. :::caption{for="error"} **Instead of writing conditionals** ::: ```edge @if(flashMessages.has('errorsBag.E_BAD_CSRF_TOKEN'))

{{ flashMessages.get('errorsBag.E_BAD_CSRF_TOKEN') }}

@end ``` :::caption{for="success"} **You may prefer using the tag** ::: ```edge @error('E_BAD_CSRF_TOKEN')

{{ $message }}

@end ``` ## @inputError The `@inputError` tag provides a better DX for reading validation error messages stored inside the `inputErrorsBag` key in `flashMessages`. :::caption{for="error"} **Instead of writing conditionals** ::: ```edge @if(flashMessages.has('inputErrorsBag.title')) @each(message in flashMessages.get('inputErrorsBag.title'))

{{ message }}

@end @end ``` :::caption{for="success"} **You may prefer using the tag** ::: ```edge @inputError('title') @each(message in $messages)

{{ message }}

@end @end ``` ## @vite The `@vite` tag accepts an array of entry point paths and returns the `script` and the `link` tags for the same. The path you provide to the `@vite` tag should match exactly the path registered inside the `vite.config.js` file. ```ts export default defineConfig({ plugins: [ adonisjs({ // highlight-start entrypoints: ['resources/js/app.js'], // highlight-end }), ] }) ``` ```edge @vite(['resources/js/app.js']) ``` You can define the script tag attributes as the 2nd argument. For example: ```edge @vite(['resources/js/app.js'], { defer: true, }) ``` ## @viteReactRefresh The `@viteReactRefresh` tag returns a [script tag to enable React HMR](https://vitejs.dev/guide/backend-integration.html#:~:text=you%27ll%20also%20need%20to%20add%20this%20before%20the%20above%20scripts) for project using the [@vitejs/plugin-react](https://www.npmjs.com/package/@vitejs/plugin-react) package. ```edge @viteReactRefresh() ``` Output HTML ```html ``` ## @can/@cannot The `@can` and `@cannot` tags allows you write authorization checks in Edge templates by referencing the ability name or the policy name as a string. The first argument is the ability or the policy reference followed by the arguments accepted by the check. See also: [Pre-registering abilities and policies](../guides/auth/authorization.md#pre-registering-abilities-and-policies) ```edge @can('editPost', post) {{-- Can edit post --}} @end @can('PostPolicy.edit', post) {{-- Can edit post --}} @end ``` ```edge @cannot('editPost', post) {{-- Cannot edit post --}} @end @cannot('editPost', post) {{-- Cannot edit post --}} @end ``` --- # Events reference In this guide, we look at the list of events dispatched by the framework core and the official packages. Check out the [emitter](../guides/digging_deeper/emitter.md) documentation to learn more about its usage. ## http\:request_completed The [`http:request_completed`](https://github.com/adonisjs/http-server/blob/8.x/src/types/server.ts#L65-L81) event is dispatched after an HTTP request is completed. The event contains an instance of the [HttpContext](../guides/basics/http_context.md) and the request duration. The `duration` value is the output of the `process.hrtime` method. ```ts import emitter from '@adonisjs/core/services/emitter' import string from '@adonisjs/core/helpers/string' emitter.on('http:request_completed', (event) => { const method = event.ctx.request.method() const url = event.ctx.request.url(true) const duration = event.duration console.log(`${method} ${url}: ${string.prettyHrTime(duration)}`) }) ``` ## http\:server_ready The event is dispatched once the AdonisJS HTTP server is ready to accept incoming requests. ```ts import emitter from '@adonisjs/core/services/emitter' emitter.on('http:server_ready', (event) => { console.log(event.host) console.log(event.port) /** * Time it took to boot the app and start * the HTTP server. */ console.log(event.duration) }) ``` ## container_binding\:resolved The event is dispatched after the IoC container resolves a binding or constructs a class instance. The `event.binding` property will be a string (binding name) or a class constructor, and the `event.value` property is the resolved value. ```ts import emitter from '@adonisjs/core/services/emitter' emitter.on('container_binding:resolved', (event) => { console.log(event.binding) console.log(event.value) }) ``` ## session\:initiated The `@adonisjs/session` package emits the event when the session store is initiated during an HTTP request. The `event.session` property is an instance of the [Session class](https://github.com/adonisjs/session/blob/8.x/src/session.ts). ```ts import emitter from '@adonisjs/core/services/emitter' emitter.on('session:initiated', (event) => { console.log(`Initiated store for ${event.session.sessionId}`) }) ``` ## session\:committed The `@adonisjs/session` package emits the event when the session data is written to the session store during an HTTP request. ```ts import emitter from '@adonisjs/core/services/emitter' emitter.on('session:committed', (event) => { console.log(`Persisted data for ${event.session.sessionId}`) }) ``` ## session\:migrated The `@adonisjs/session` package emits the event when a new session ID is generated using the `session.regenerate()` method. ```ts import emitter from '@adonisjs/core/services/emitter' emitter.on('session:migrated', (event) => { console.log(`Migrating data to ${event.toSessionId}`) console.log(`Destroying session ${event.fromSessionId}`) }) ``` ## i18n\:missing\:translation The event is dispatched by the `@adonisjs/i18n` package when a translation for a specific key and locale is missing. You may listen to this event to find the missing translations for a given locale. ```ts import emitter from '@adonisjs/core/services/emitter' emitter.on('i18n:missing:translation', function (event) { console.log(event.identifier) console.log(event.hasFallback) console.log(event.locale) }) ``` ## mail\:sending The `@adonisjs/mail` package emits the event before sending an email. In the case of the `mail.sendLater` method call, the event will be emitted when the mail queue processes the job. ```ts import emitter from '@adonisjs/core/services/emitter' emitter.on('mail:sending', (event) => { console.log(event.mailerName) console.log(event.message) console.log(event.views) }) ``` ## mail\:sent After sending the email, the event is dispatched by the `@adonisjs/mail` package. ```ts import emitter from '@adonisjs/core/services/emitter' emitter.on('mail:sent', (event) => { console.log(event.response) console.log(event.mailerName) console.log(event.message) console.log(event.views) }) ``` ## mail\:queueing The `@adonisjs/mail` package emits the event before queueing the job to send the email. ```ts import emitter from '@adonisjs/core/services/emitter' emitter.on('mail:queueing', (event) => { console.log(event.mailerName) console.log(event.message) console.log(event.views) }) ``` ## mail\:queued After the email has been queued, the event is dispatched by the `@adonisjs/mail` package. ```ts import emitter from '@adonisjs/core/services/emitter' emitter.on('mail:queued', (event) => { console.log(event.mailerName) console.log(event.message) console.log(event.views) }) ``` ## queued\:mail\:error The event is dispatched when the [MemoryQueue](https://github.com/adonisjs/mail/blob/10.x/src/messengers/memory_queue.ts) implementation of the `@adonisjs/mail` package is unable to send the email queued using the `mail.sendLater` method. If you are using a custom queue implementation, you must capture the job errors and emit this event. ```ts import emitter from '@adonisjs/core/services/emitter' emitter.on('queued:mail:error', (event) => { console.log(event.error) console.log(event.mailerName) }) ``` ## session_auth\:login_attempted The event is dispatched by the [SessionGuard](https://github.com/adonisjs/auth/blob/10.x/modules/session_guard/guard.ts) implementation of the `@adonisjs/auth` package when the `auth.login` method is called either directly or internally by the session guard. ```ts import emitter from '@adonisjs/core/services/emitter' emitter.on('session_auth:login_attempted', (event) => { console.log(event.guardName) console.log(event.user) }) ``` ## session_auth\:login_succeeded The event is dispatched by the [SessionGuard](https://github.com/adonisjs/auth/blob/10.x/modules/session_guard/guard.ts) implementation of the `@adonisjs/auth` package after a user has been logged in successfully. You may use this event to track sessions associated with a given user. ```ts import emitter from '@adonisjs/core/services/emitter' emitter.on('session_auth:login_succeeded', (event) => { console.log(event.guardName) console.log(event.sessionId) console.log(event.user) console.log(event.rememberMeToken) // (if created one) }) ``` ## session_auth\:authentication_attempted The event is dispatched by the `@adonisjs/auth` package when an attempt is made to validate the request session and check for a logged-in user. ```ts import emitter from '@adonisjs/core/services/emitter' emitter.on('session_auth:authentication_attempted', (event) => { console.log(event.guardName) console.log(event.sessionId) }) ``` ## session_auth\:authentication_succeeded The event is dispatched by the `@adonisjs/auth` package after the request session has been validated and the user is logged in. You may access the logged-in user using the `event.user` property. ```ts import emitter from '@adonisjs/core/services/emitter' emitter.on('session_auth:authentication_succeeded', (event) => { console.log(event.guardName) console.log(event.sessionId) console.log(event.user) console.log(event.rememberMeToken) // if authenticated using token }) ``` ## session_auth\:authentication_failed The event is dispatched by the `@adonisjs/auth` package when the authentication check fails, and the user is not logged in during the current HTTP request. ```ts import emitter from '@adonisjs/core/services/emitter' emitter.on('session_auth:authentication_failed', (event) => { console.log(event.guardName) console.log(event.sessionId) console.log(event.error) }) ``` ## session_auth\:logged_out The event is dispatched by the `@adonisjs/auth` package after the user has been logged out. ```ts import emitter from '@adonisjs/core/services/emitter' emitter.on('session_auth:logged_out', (event) => { console.log(event.guardName) console.log(event.sessionId) /** * The value of the user will be null when logout is called * during a request where no user was logged in in the first place. */ console.log(event.user) }) ``` ## access_tokens_auth\:authentication_attempted The event is dispatched by the `@adonisjs/auth` package when an attempt is made to validate the access token during an HTTP request. ```ts import emitter from '@adonisjs/core/services/emitter' emitter.on('access_tokens_auth:authentication_attempted', (event) => { console.log(event.guardName) }) ``` ## access_tokens_auth\:authentication_succeeded The event is dispatched by the `@adonisjs/auth` package after the access token has been verified. You may access the authenticated user using the `event.user` property. ```ts import emitter from '@adonisjs/core/services/emitter' emitter.on('access_tokens_auth:authentication_succeeded', (event) => { console.log(event.guardName) console.log(event.user) console.log(event.token) }) ``` ## access_tokens_auth\:authentication_failed The event is dispatched by the `@adonisjs/auth` package when the authentication check fails. ```ts import emitter from '@adonisjs/core/services/emitter' emitter.on('access_tokens_auth:authentication_failed', (event) => { console.log(event.guardName) console.log(event.error) }) ``` ## authorization\:finished The event is dispatched by the `@adonisjs/bouncer` package after the authorization check has been performed. The event payload includes the final response you may inspect to know the status of the check. ```ts import emitter from '@adonisjs/core/services/emitter' emitter.on('authorization:finished', (event) => { console.log(event.user) console.log(event.response) console.log(event.parameters) console.log(event.action) }) ``` ## cache\:cleared The event is dispatched by the `@adonisjs/cache` package after the cache has been cleared using the `cache.clear` method. ```ts import emitter from '@adonisjs/core/services/emitter' emitter.on('cache:cleared', (event) => { console.log(event.store) }) ``` ## cache\:deleted The event is dispatched by the `@adonisjs/cache` package after a cache key has been deleted using the `cache.delete` method. ```ts import emitter from '@adonisjs/core/services/emitter' emitter.on('cache:deleted', (event) => { console.log(event.key) }) ``` ## cache\:hit The event is dispatched by the `@adonisjs/cache` package when a cache key is found in the cache store. ```ts import emitter from '@adonisjs/core/services/emitter' emitter.on('cache:hit', (event) => { console.log(event.key) console.log(event.value) }) ``` ## cache\:miss The event is dispatched by the `@adonisjs/cache` package when a cache key is not found in the cache store. ```ts import emitter from '@adonisjs/core/services/emitter' emitter.on('cache:miss', (event) => { console.log(event.key) }) ``` ## cache\:written The event is dispatched by the `@adonisjs/cache` package after a cache key has been written to the cache store. ```ts import emitter from '@adonisjs/core/services/emitter' emitter.on('cache:written', (event) => { console.log(event.key) console.log(event.value) }) ``` --- # Exceptions reference In this guide we will go through the list of known exceptions raised by the framework core and the official packages. Some of the exceptions are marked as **self-handled**. [Self-handled exceptions](../guides/basics/exception_handling.md#defining-the-handle-method) can convert themselves to an HTTP response. ## E_ROUTE_NOT_FOUND The exception is raised when the HTTP server receives a request for a non-existing route. By default, the client will get a 404 response, and optionally, you may render an HTML page using [status pages](../guides/basics/exception_handling.md#status-pages). - **Status code**: 404 - **Self handled**: No ```ts import { errors } from '@adonisjs/core' if (error instanceof errors.E_ROUTE_NOT_FOUND) { // handle error } ``` ## E_ROW_NOT_FOUND The exception is raised when the database query for finding one item fails [e.g when using `Model.findOrFail()`]. By default, the client will get a 404 response, and optionally, you may render an HTML page using [status pages](../guides/basics/exception_handling.md#status-pages). - **Status code**: 404 - **Self handled**: No ```ts import { errors as lucidErrors } from '@adonisjs/lucid' if (error instanceof lucidErrors.E_ROW_NOT_FOUND) { // handle error console.log(`${error.model?.name || 'Row'} not found`) } ``` ## E_AUTHORIZATION_FAILURE The exception is raised when a bouncer authorization check fails. The exception is self-handled and [uses content-negotiation](../guides/auth/authorization.md#throwing-authorizationexception) to return an appropriate error response to the client. - **Status code**: 403 - **Self handled**: Yes - **Translation identifier**: `errors.E_AUTHORIZATION_FAILURE` ```ts import { errors as bouncerErrors } from '@adonisjs/bouncer' if (error instanceof bouncerErrors.E_AUTHORIZATION_FAILURE) { } ``` ## E_TOO_MANY_REQUESTS The exception is raised by the [@adonisjs/rate-limiter](../guides/security/rate_limiting.md) package when a request exhausts all the requests allowed during a given duration. The exception is self-handled and [uses content-negotiation](../guides/security/rate_limiting.md#handling-throttleexception) to return an appropriate error response to the client. - **Status code**: 429 - **Self handled**: Yes - **Translation identifier**: `errors.E_TOO_MANY_REQUESTS` ```ts import { errors as limiterErrors } from '@adonisjs/limiter' if (error instanceof limiterErrors.E_TOO_MANY_REQUESTS) { } ``` ## E_BAD_CSRF_TOKEN The exception is raised when a form using [CSRF protection](../guides/security/securing_ssr_applications.md#csrf-protection) is submitted without the CSRF token, or the CSRF token is invalid. - **Status code**: 403 - **Self handled**: Yes - **Translation identifier**: `errors.E_BAD_CSRF_TOKEN` ```ts import { errors as shieldErrors } from '@adonisjs/shield' if (error instanceof shieldErrors.E_BAD_CSRF_TOKEN) { } ``` The `E_BAD_CSRF_TOKEN` exception is [self-handled](https://github.com/adonisjs/shield/blob/9.x/src/errors.ts#L23-L66), and the user will be redirected back to the form, and you can access the error using the flash messages. ```edge @error('E_BAD_CSRF_TOKEN')

{{ message }}

@end ``` ## E_OAUTH_MISSING_CODE The `@adonisjs/ally` package raises the exception when the OAuth service does not provide the OAuth code during the redirect. You can avoid this exception if you [handle the errors](../guides/auth/social_authentication.md#handling-callback-response) before calling the `.accessToken` or `.user` methods. - **Status code**: 500 - **Self handled**: No ```ts import { errors as allyErrors } from '@adonisjs/ally' if (error instanceof allyErrors.E_OAUTH_MISSING_CODE) { } ``` ## E_OAUTH_STATE_MISMATCH The `@adonisjs/ally` package raises the exception when the CSRF state defined during the redirect is missing. You can avoid this exception if you [handle the errors](../guides/auth/social_authentication.md#handling-callback-response) before calling the `.accessToken` or `.user` methods. - **Status code**: 400 - **Self handled**: No ```ts import { errors as allyErrors } from '@adonisjs/ally' if (error instanceof allyErrors.E_OAUTH_STATE_MISMATCH) { } ``` ## E_UNAUTHORIZED_ACCESS The exception is raised when one of the authentication guards is not able to authenticate the request. The exception is self-handled and uses [content-negotiation](../guides/auth/session_guard.md#handling-authentication-exception) to return an appropriate error response to the client. - **Status code**: 401 - **Self handled**: Yes - **Translation identifier**: `errors.E_UNAUTHORIZED_ACCESS` ```ts import { errors as authErrors } from '@adonisjs/auth' if (error instanceof authErrors.E_UNAUTHORIZED_ACCESS) { } ``` ## E_INVALID_CREDENTIALS The exception is raised when the auth finder is not able to verify the user credentials. The exception is handled and use [content-negotiation](../guides/auth/verifying_user_credentials.md#handling-exceptions) to return an appropriate error response to the client. - **Status code**: 400 - **Self handled**: Yes - **Translation identifier**: `errors.E_INVALID_CREDENTIALS` ```ts import { errors as authErrors } from '@adonisjs/auth' if (error instanceof authErrors.E_INVALID_CREDENTIALS) { } ``` ## E_CANNOT_LOOKUP_ROUTE The exception is raised when you attempt to create a URL for a route using the [URL builder](../guides/basics/routing.md#url-builder). - **Status code**: 500 - **Self handled**: No ```ts import { errors } from '@adonisjs/core' if (error instanceof errors.E_CANNOT_LOOKUP_ROUTE) { // handle error } ``` ## E_HTTP_EXCEPTION The `E_HTTP_EXCEPTION` is a generic exception for throwing errors during an HTTP request. You can use this exception directly or create a custom exception extending it. - **Status code**: Defined at the time of raising the exception - **Self handled**: Yes ```ts // title: Throw exception import { errors } from '@adonisjs/core' throw errors.E_HTTP_EXCEPTION.invoke( { errors: ['Cannot process request'], }, 422 ) ``` ```ts // title: Handle exception import { errors } from '@adonisjs/core' if (error instanceof errors.E_HTTP_EXCEPTION) { // handle error } ``` ## E_HTTP_REQUEST_ABORTED The `E_HTTP_REQUEST_ABORTED` is a sub-class of the `E_HTTP_EXCEPTION` exception. This exception is raised by the [response.abort](../guides/basics/response.md#aborting-request-with-an-error) method. ```ts import { errors } from '@adonisjs/core' if (error instanceof errors.E_HTTP_REQUEST_ABORTED) { // handle error } ``` ## E_INSECURE_APP_KEY The exception is raised when the length of `appKey` is smaller than 16 characters. You can use the [generate:key](./commands.md#generatekey) ace command to generate a secure app key. - **Status code**: 500 - **Self handled**: No ```ts import { errors } from '@adonisjs/core' if (error instanceof errors.E_INSECURE_APP_KEY) { // handle error } ``` ## E_MISSING_APP_KEY The exception is raised when the `appKey` property is not defined inside the `config/app.ts` file. By default, the value of the `appKey` is set using the `APP_KEY` environment variable. - **Status code**: 500 - **Self handled**: No ```ts import { errors } from '@adonisjs/core' if (error instanceof errors.E_MISSING_APP_KEY) { // handle error } ``` ## E_INVALID_ENV_VARIABLES The exception is raised when one or more environment variables fail the validation. The detailed validation errors can be accessed using the `error.help` property. - **Status code**: 500 - **Self handled**: No ```ts import { errors } from '@adonisjs/core' if (error instanceof errors.E_INVALID_ENV_VARIABLES) { console.log(error.help) } ``` ## E_MISSING_COMMAND_NAME The exception is raised when a command does not define the `commandName` property or its value is an empty string. - **Status code**: 500 - **Self handled**: No ```ts import { errors } from '@adonisjs/core' if (error instanceof errors.E_MISSING_COMMAND_NAME) { console.log(error.commandName) } ``` ## E_COMMAND_NOT_FOUND The exception is raised by Ace when unable to find a command. - **Status code**: 404 - **Self handled**: No ```ts import { errors } from '@adonisjs/core' if (error instanceof errors.E_COMMAND_NOT_FOUND) { console.log(error.commandName) } ``` ## E_MISSING_FLAG The exception is raised when executing a command without passing a required CLI flag. - **Status code**: 500 - **Self handled**: No ```ts import { errors } from '@adonisjs/core' if (error instanceof errors.E_MISSING_FLAG) { console.log(error.commandName) } ``` ## E_MISSING_FLAG_VALUE The exception is raised when trying to execute a command without providing any value to a non-boolean CLI flag. - **Status code**: 500 - **Self handled**: No ```ts import { errors } from '@adonisjs/core' if (error instanceof errors.E_MISSING_FLAG_VALUE) { console.log(error.commandName) } ``` ## E_MISSING_ARG The exception is raised when executing a command without defining the required arguments. - **Status code**: 500 - **Self handled**: No ```ts import { errors } from '@adonisjs/core' if (error instanceof errors.E_MISSING_ARG) { console.log(error.commandName) } ``` ## E_MISSING_ARG_VALUE The exception is raised when executing a command without defining the value for a required argument. - **Status code**: 500 - **Self handled**: No ```ts import { errors } from '@adonisjs/core' if (error instanceof errors.E_MISSING_ARG_VALUE) { console.log(error.commandName) } ``` ## E_UNKNOWN_FLAG The exception is raised when executing a command with an unknown CLI flag. - **Status code**: 500 - **Self handled**: No ```ts import { errors } from '@adonisjs/core' if (error instanceof errors.E_UNKNOWN_FLAG) { console.log(error.commandName) } ``` ## E_INVALID_FLAG The exception is raised when the value provided for a CLI flag is invalid—for example, passing a string value to a flag that accepts numeric values. - **Status code**: 500 - **Self handled**: No ```ts import { errors } from '@adonisjs/core' if (error instanceof errors.E_INVALID_FLAG) { console.log(error.commandName) } ``` ## E_MULTIPLE_REDIS_SUBSCRIPTIONS The `@adonisjs/redis` package raises the exception when you attempt to [subscribe to a given pub/sub channel](../guides/database/redis.md#pubsub) multiple times. - **Status code**: 500 - **Self handled**: No ```ts import { errors as redisErrors } from '@adonisjs/redis' if (error instanceof redisErrors.E_MULTIPLE_REDIS_SUBSCRIPTIONS) { } ``` ## E_MULTIPLE_REDIS_PSUBSCRIPTIONS The `@adonisjs/redis` package raises the exception when you attempt to [subscribe to a given pub/sub pattern](../guides/database/redis.md#pubsub) multiple times. - **Status code**: 500 - **Self handled**: No ```ts import { errors as redisErrors } from '@adonisjs/redis' if (error instanceof redisErrors.E_MULTIPLE_REDIS_PSUBSCRIPTIONS) { } ``` ## E_MAIL_TRANSPORT_ERROR The exception is raised by the `@adonisjs/mail` package when unable to send the email using a given transport. Usually, this will happen when the HTTP API of the email service returns a non-200 HTTP response. You may access the network request error using the `error.cause` property. The `cause` property is the [error object](https://github.com/sindresorhus/got/blob/main/documentation/8-errors.md) returned by `got` (npm package). - **Status code**: 400 - **Self handled**: No ```ts import { errors as mailErrors } from '@adonisjs/mail' if (error instanceof mailErrors.E_MAIL_TRANSPORT_ERROR) { console.log(error.cause) } ``` ## E_SESSION_NOT_MUTABLE The exception is raised by the `@adonisjs/session` package when the session store is initiated in the read-only mode. - **Status code**: 500 - **Self handled**: No ```ts import { errors as sessionErrors } from '@adonisjs/session' if (error instanceof sessionErrors.E_SESSION_NOT_MUTABLE) { console.log(error.message) } ``` ## E_SESSION_NOT_READY The exception is raised by the `@adonisjs/session` package when the session store has not been initiated yet. This will be the case when you are not using the session middleware. - **Status code**: 500 - **Self handled**: No ```ts import { errors as sessionErrors } from '@adonisjs/session' if (error instanceof sessionErrors.E_SESSION_NOT_READY) { console.log(error.message) } ``` ## E_MISSING_METAFILE_PATTERN The exception is raised when the `pattern` property is missing in the [metaFile](./adonisrc_file.md#metaFiles). - **Status code**: 500 - **Self handled**: No ```ts import { errors } from '@adonisjs/core' if (error instanceof errors.E_MISSING_METAFILE_PATTERN) { console.log(error.message) } ``` ## E_MISSING_PRELOAD_FILE The exception is raised when a preload file registered in the `adonisrc.ts` file cannot be found. - **Status code**: 500 - **Self handled**: No ```ts import { errors } from '@adonisjs/core' if (error instanceof errors.E_MISSING_PRELOAD_FILE) { console.log(error.message) } ``` ## E_INVALID_PRELOAD_FILE The exception is raised when the preload `file` property is not a function. - **Status code**: 500 - **Self handled**: No ```ts import { errors } from '@adonisjs/core' if (error instanceof errors.E_INVALID_PRELOAD_FILE) { console.log(error.message) } ``` ## E_MISSING_PROVIDER_FILE The exception is raised when a service provider file registered in the `adonisrc.ts` file cannot be found. - **Status code**: 500 - **Self handled**: No ```ts import { errors } from '@adonisjs/core' if (error instanceof errors.E_MISSING_PROVIDER_FILE) { console.log(error.message) } ``` ## E_INVALID_PROVIDER The exception is raised when the provider `file` property is not a function. - **Status code**: 500 - **Self handled**: No ```ts import { errors } from '@adonisjs/core' if (error instanceof errors.E_INVALID_PROVIDER) { console.log(error.message) } ``` ## E_MISSING_SUITE_NAME The exception is raised when a test suite configuration in the `adonisrc.ts` file is missing the required `name` property. - **Status code**: 500 - **Self handled**: No ```ts import { errors } from '@adonisjs/core' if (error instanceof errors.E_MISSING_SUITE_NAME) { console.log(error.message) } ``` ## E_MISSING_SUITE_FILES The exception is raised when a test suite configuration in the `adonisrc.ts` file is missing the required `files` property. - **Status code**: 500 - **Self handled**: No ```ts import { errors } from '@adonisjs/core' if (error instanceof errors.E_MISSING_SUITE_FILES) { console.log(error.message) } ``` ## E_UNKNOWN_ASSEMBLER_HOOK The exception is raised when an unknown assembler hook is defined in the `adonisrc.ts` file. - **Status code**: 500 - **Self handled**: No ```ts import { errors } from '@adonisjs/core' if (error instanceof errors.E_UNKNOWN_ASSEMBLER_HOOK) { console.log(error.message) } ``` ## E_INVALID_HOOKS_VALUE The exception is raised when the `hooks` configuration in the `adonisrc.ts` file is not an array of lazy imports. - **Status code**: 500 - **Self handled**: No ```ts import { errors } from '@adonisjs/core' if (error instanceof errors.E_INVALID_HOOKS_VALUE) { console.log(error.message) } ``` ## E_INVALID_PRESETS_VALUE The exception is raised when the `presets` configuration is not an array of preset functions. - **Status code**: 500 - **Self handled**: No ```ts import { errors } from '@adonisjs/core' if (error instanceof errors.E_INVALID_PRESETS_VALUE) { console.log(error.message) } ``` ## E_INVALID_PRESET_FUNCTION The exception is raised when a preset is not defined as a function. - **Status code**: 500 - **Self handled**: No ```ts import { errors } from '@adonisjs/core' if (error instanceof errors.E_INVALID_PRESET_FUNCTION) { console.log(error.message) } ``` ## E_PRESET_EXECUTION_ERROR The exception is raised when an error occurs during preset execution. - **Status code**: 500 - **Self handled**: No ```ts import { errors } from '@adonisjs/core' if (error instanceof errors.E_PRESET_EXECUTION_ERROR) { console.log(error.message) console.log(error.cause) } ``` ## E_INVALID_ENV_VARIABLES The exception is raised when one or more environment variables are invalid as per the rules defined within the `start/env.ts` file. - **Status code**: 500 - **Self handled**: No ```ts import { errors } from '@adonisjs/core' if (error instanceof errors.E_INVALID_ENV_VARIABLES) { console.log(error.message) console.log(error.help) } ``` ## E_IDENTIFIER_ALREADY_DEFINED The exception is raised when trying to override the implementation of an Env identifier. Consider using `Env.defineIdentifierIfMissing` method instead. - **Status code**: 500 - **Self handled**: No ```ts import { errors } from '@adonisjs/core' if (error instanceof errors.E_IDENTIFIER_ALREADY_DEFINED) { console.log(error.message) } ``` --- # Helpers reference AdonisJS bundles its utilities into the `helpers` module and makes them available to your application code. Since these utilities are already installed and used by the framework, the `helpers` module does not add any additional bloat to your `node_modules`. The helper methods are exported from the following modules. ```ts import is from '@adonisjs/core/helpers/is' import * as helpers from '@adonisjs/core/helpers' import string from '@adonisjs/core/helpers/string' ``` ## escapeHTML Escape HTML entities in a string value. Under the hood, we use the [he](https://www.npmjs.com/package/he#heescapetext) package. ```ts import string from '@adonisjs/core/helpers/string' string.escapeHTML('

foo © bar

') // <p> foo © bar </p> ``` Optionally, you can encode non-ASCII symbols using the `encodeSymbols` option. ```ts import string from '@adonisjs/core/helpers/string' string.escapeHTML('

foo © bar

', { encodeSymbols: true, }) // <p> foo © bar </p> ``` ## encodeSymbols You may encode non-ASCII symbols in a string value using the `encodeSymbols` helper. Under the hood, we use [he.encode](https://www.npmjs.com/package/he#heencodetext-options) method. ```ts import string from '@adonisjs/core/helpers/string' string.encodeSymbols('foo © bar ≠ baz 𝌆 qux') // 'foo © bar ≠ baz 𝌆 qux' ``` ## prettyHrTime Pretty print the diff of [process.hrtime](https://nodejs.org/api/process.html#processhrtimetime) method. ```ts import { hrtime } from 'node:process' import string from '@adonisjs/core/helpers/string' const startTime = hrtime() await someOperation() const endTime = hrtime(startTime) console.log(string.prettyHrTime(endTime)) ``` ## isEmpty Check if a string value is empty. ```ts import string from '@adonisjs/core/helpers/string' string.isEmpty('') // true string.isEmpty(' ') // true ``` ## truncate Truncate a string at a given number of characters. ```ts import string from '@adonisjs/core/helpers/string' string.truncate('This is a very long, maybe not that long title', 12) // Output: This is a ve... ``` By default, the string is truncated exactly at the given index. However, you can instruct the method to wait for the words to complete. ```ts string.truncate('This is a very long, maybe not that long title', 12, { completeWords: true, }) // Output: This is a very... ``` You can customize the suffix using the `suffix` option. ```ts string.truncate('This is a very long, maybe not that long title', 12, { completeWords: true, suffix: '... Read more ', }) // Output: This is a very... Read more ``` ## excerpt The `excerpt` method is identical to the `truncate` method. However, it strips the HTML tags from the string. ```ts import string from '@adonisjs/core/helpers/string' string.excerpt('

This is a very long, maybe not that long title

', 12, { completeWords: true, }) // Output: This is a very... ``` ## slug Generate slug for a string value. The method is exported from the [slugify package](https://www.npmjs.com/package/slugify); therefore, consult its documentation for available options. ```ts import string from '@adonisjs/core/helpers/string' console.log(string.slug('hello ♥ world')) // hello-love-world ``` You can add custom replacements for Unicode values as follows. ```ts string.slug.extend({ '☢': 'radioactive' }) console.log(string.slug('unicode ♥ is ☢')) // unicode-love-is-radioactive ``` ## interpolate Interpolate variables inside a string. The variables must be inside double curly braces. ```ts import string from '@adonisjs/core/helpers/string' string.interpolate('hello {{ user.username }}', { user: { username: 'virk' } }) // hello virk ``` Curly braces can be escaped using the `\\` prefix. ```ts string.interpolate('hello \\{{ users.0 }}', {}) // hello {{ users.0 }} ``` ## plural Convert a word to its plural form. The method is exported from the [pluralize package](https://www.npmjs.com/package/pluralize). ```ts import string from '@adonisjs/core/helpers/string' string.plural('test') // tests ``` ## isPlural Find if a word already is in plural form. ```ts import string from '@adonisjs/core/helpers/string' string.isPlural('tests') // true ``` ## pluralize This method combines the `singular` and the `plural` methods and uses one or the other based on the count. For example: ```ts import string from '@adonisjs/core/helpers/string' string.pluralize('box', 1) // box string.pluralize('box', 2) // boxes string.pluralize('box', 0) // boxes string.pluralize('boxes', 1) // box string.pluralize('boxes', 2) // boxes string.pluralize('boxes', 0) // boxes ``` The `pluralize` property exports [additional methods](https://www.npmjs.com/package/pluralize) to register custom uncountable, irregular, plural, and singular rules. ```ts import string from '@adonisjs/core/helpers/string' string.pluralize.addUncountableRule('paper') string.pluralize.addSingularRule(/singles$/i, 'singular') ``` ## singular Convert a word to its singular form. The method is exported from the [pluralize package](https://www.npmjs.com/package/pluralize). ```ts import string from '@adonisjs/core/helpers/string' string.singular('tests') // test ``` ## isSingular Find if a word is already in a singular form. ```ts import string from '@adonisjs/core/helpers/string' string.isSingular('test') // true ``` ## camelCase Convert a string value to camelcase. ```ts import string from '@adonisjs/core/helpers/string' string.camelCase('user_name') // userName ``` Following are some of the conversion examples. | Input | Output | | ---------------- | ------------- | | 'test' | 'test' | | 'test string' | 'testString' | | 'Test String' | 'testString' | | 'TestV2' | 'testV2' | | '_foo_bar_' | 'fooBar' | | 'version 1.2.10' | 'version1210' | | 'version 1.21.0' | 'version1210' | ## capitalCase Convert a string value to a capital case. ```ts import string from '@adonisjs/core/helpers/string' string.capitalCase('helloWorld') // Hello World ``` Following are some of the conversion examples. | Input | Output | | ---------------- | ---------------- | | 'test' | 'Test' | | 'test string' | 'Test String' | | 'Test String' | 'Test String' | | 'TestV2' | 'Test V 2' | | 'version 1.2.10' | 'Version 1.2.10' | | 'version 1.21.0' | 'Version 1.21.0' | ## dashCase Convert a string value to a dash case. ```ts import string from '@adonisjs/core/helpers/string' string.dashCase('helloWorld') // hello-world ``` Optionally, you can capitalize the first letter of each word. ```ts string.dashCase('helloWorld', { capitalize: true }) // Hello-World ``` Following are some of the conversion examples. | Input | Output | | ---------------- | -------------- | | 'test' | 'test' | | 'test string' | 'test-string' | | 'Test String' | 'test-string' | | 'Test V2' | 'test-v2' | | 'TestV2' | 'test-v-2' | | 'version 1.2.10' | 'version-1210' | | 'version 1.21.0' | 'version-1210' | ## dotCase Convert a string value to a dot case. ```ts import string from '@adonisjs/core/helpers/string' string.dotCase('helloWorld') // hello.World ``` Optionally, you can convert the first letter of all the words to lowercase. ```ts string.dotCase('helloWorld', { lowerCase: true }) // hello.world ``` Following are some of the conversion examples. | Input | Output | | ---------------- | -------------- | | 'test' | 'test' | | 'test string' | 'test.string' | | 'Test String' | 'Test.String' | | 'dot.case' | 'dot.case' | | 'path/case' | 'path.case' | | 'TestV2' | 'Test.V.2' | | 'version 1.2.10' | 'version.1210' | | 'version 1.21.0' | 'version.1210' | ## noCase Remove all sorts of casing from a string value. ```ts import string from '@adonisjs/core/helpers/string' string.noCase('helloWorld') // hello world ``` Following are some of the conversion examples. | Input | Output | | ---------------------- | ---------------------- | | 'test' | 'test' | | 'TEST' | 'test' | | 'testString' | 'test string' | | 'testString123' | 'test string123' | | 'testString_1_2_3' | 'test string 1 2 3' | | 'ID123String' | 'id123 string' | | 'foo bar123' | 'foo bar123' | | 'a1bStar' | 'a1b star' | | 'CONSTANT_CASE ' | 'constant case' | | 'CONST123_FOO' | 'const123 foo' | | 'FOO_bar' | 'foo bar' | | 'XMLHttpRequest' | 'xml http request' | | 'IQueryAArgs' | 'i query a args' | | 'dot\.case' | 'dot case' | | 'path/case' | 'path case' | | 'snake_case' | 'snake case' | | 'snake_case123' | 'snake case123' | | 'snake_case_123' | 'snake case 123' | | '"quotes"' | 'quotes' | | 'version 0.45.0' | 'version 0 45 0' | | 'version 0..78..9' | 'version 0 78 9' | | 'version 4_99/4' | 'version 4 99 4' | | ' test ' | 'test' | | 'something_2014_other' | 'something 2014 other' | | 'amazon s3 data' | 'amazon s3 data' | | 'foo_13_bar' | 'foo 13 bar' | ## pascalCase Convert a string value to a Pascal case. Great for generating JavaScript class names. ```ts import string from '@adonisjs/core/helpers/string' string.pascalCase('user team') // UserTeam ``` Following are some of the conversion examples. | Input | Output | | ---------------- | ------------- | | 'test' | 'Test' | | 'test string' | 'TestString' | | 'Test String' | 'TestString' | | 'TestV2' | 'TestV2' | | 'version 1.2.10' | 'Version1210' | | 'version 1.21.0' | 'Version1210' | ## sentenceCase Convert a value to a sentence. ```ts import string from '@adonisjs/core/helpers/string' string.sentenceCase('getting_started-with-adonisjs') // Getting started with adonisjs ``` Following are some of the conversion examples. | Input | Output | | ---------------- | ---------------- | | 'test' | 'Test' | | 'test string' | 'Test string' | | 'Test String' | 'Test string' | | 'TestV2' | 'Test v2' | | 'version 1.2.10' | 'Version 1 2 10' | | 'version 1.21.0' | 'Version 1 21 0' | ## snakeCase Convert value to snake case. ```ts import string from '@adonisjs/core/helpers/string' string.snakeCase('user team') // user_team ``` Following are some of the conversion examples. | Input | Output | | ---------------- | -------------- | | '\_id' | 'id' | | 'test' | 'test' | | 'test string' | 'test_string' | | 'Test String' | 'test_string' | | 'Test V2' | 'test_v2' | | 'TestV2' | 'test_v_2' | | 'version 1.2.10' | 'version_1210' | | 'version 1.21.0' | 'version_1210' | ## titleCase Convert a string value to the title case. ```ts import string from '@adonisjs/core/helpers/string' string.titleCase('small word ends on') // Small Word Ends On ``` Following are some of the conversion examples. | Input | Output | | ---------------------------------- | ---------------------------------- | | 'one. two.' | 'One. Two.' | | 'a small word starts' | 'A Small Word Starts' | | 'small word ends on' | 'Small Word Ends On' | | 'we keep NASA capitalized' | 'We Keep NASA Capitalized' | | 'pass camelCase through' | 'Pass camelCase Through' | | 'follow step-by-step instructions' | 'Follow Step-by-Step Instructions' | | 'this vs. that' | 'This vs. That' | | 'this vs that' | 'This vs That' | | 'newcastle upon tyne' | 'Newcastle upon Tyne' | | 'newcastle \*upon\* tyne' | 'Newcastle \*upon\* Tyne' | ## random Generate a cryptographically secure random string of a given length. The output value is a URL-safe base64 encoded string. ```ts import string from '@adonisjs/core/helpers/string' string.random(32) // 8mejfWWbXbry8Rh7u8MW3o-6dxd80Thk ``` ## sentence Convert an array of words to a comma-separated sentence. ```ts import string from '@adonisjs/core/helpers/string' string.sentence(['routes', 'controllers', 'middleware']) // routes, controllers, and middleware ``` You can replace the `and` with an `or` by specifying the `options.lastSeparator` property. ```ts string.sentence(['routes', 'controllers', 'middleware'], { lastSeparator: ', or ', }) ``` In the following example, the two words are combined using the `and` separator, not the comma (usually advocated in English). However, you can use a custom separator for a pair of words. ```ts string.sentence(['routes', 'controllers']) // routes and controllers string.sentence(['routes', 'controllers'], { pairSeparator: ', and ', }) // routes, and controllers ``` ## condenseWhitespace Remove multiple whitespaces from a string to a single whitespace. ```ts import string from '@adonisjs/core/helpers/string' string.condenseWhitespace('hello world') // hello world string.condenseWhitespace(' hello world ') // hello world ``` ## seconds Parse a string-based time expression to seconds. ```ts import string from '@adonisjs/core/helpers/string' string.seconds.parse('10h') // 36000 string.seconds.parse('1 day') // 86400 ``` Passing a numeric value to the `parse` method is returned as it is, assuming the value is already in seconds. ```ts string.seconds.parse(180) // 180 ``` You can format seconds to a pretty string using the `format` method. ```ts string.seconds.format(36000) // 10h string.seconds.format(36000, true) // 10 hours ``` ## milliseconds Parse a string-based time expression to milliseconds. ```ts import string from '@adonisjs/core/helpers/string' string.milliseconds.parse('1 h') // 3.6e6 string.milliseconds.parse('1 day') // 8.64e7 ``` Passing a numeric value to the `parse` method is returned as it is, assuming the value is already in milliseconds. ```ts string.milliseconds.parse(180) // 180 ``` Using the `format` method, you can format milliseconds to a pretty string. ```ts string.milliseconds.format(3.6e6) // 1h string.milliseconds.format(3.6e6, true) // 1 hour ``` ## bytes Parse a string-based unit expression to bytes. ```ts import string from '@adonisjs/core/helpers/string' string.bytes.parse('1KB') // 1024 string.bytes.parse('1MB') // 1048576 ``` Passing a numeric value to the `parse` method is returned as it is, assuming the value is already in bytes. ```ts string.bytes.parse(1024) // 1024 ``` Using the `format` method, you can format bytes to a pretty string. The method is exported directly from the [bytes](https://www.npmjs.com/package/bytes) package. Please reference the package README for available options. ```ts string.bytes.format(1048576) // 1MB string.bytes.format(1024 * 1024 * 1000) // 1000MB string.bytes.format(1024 * 1024 * 1000, { thousandsSeparator: ',' }) // 1,000MB ``` ## ordinal Get the ordinal letter for a given number. ```ts import string from '@adonisjs/core/helpers/string' string.ordinal(1) // 1st string.ordinal(2) // '2nd' string.ordinal(3) // '3rd' string.ordinal(4) // '4th' string.ordinal(23) // '23rd' string.ordinal(24) // '24th' ``` ## safeEqual Check if two buffer or string values are the same. This method does not leak any timing information and prevents [timing attack](https://javascript.plainenglish.io/what-are-timing-attacks-and-how-to-prevent-them-using-nodejs-158cc7e2d70c). Under the hood, this method uses Node.js [crypto.timeSafeEqual](https://nodejs.org/api/crypto.html#cryptotimingsafeequala-b) method, with support for comparing string values. _(crypto.timeSafeEqual does not support string comparison)_ ```ts import { safeEqual } from '@adonisjs/core/helpers' /** * The trusted value, it might be saved inside the db */ const trustedValue = 'hello world' /** * Untrusted user input */ const userInput = 'hello' if (safeEqual(trustedValue, userInput)) { // both are the same } else { // value mismatch } ``` ## safeTiming Ensure a callback takes at least a minimum amount of time to execute. This prevents [timing attacks](https://en.wikipedia.org/wiki/Timing_attack) where an attacker measures response times to infer sensitive information (for example, user enumeration via password reset). The first argument is the minimum execution time in milliseconds. If the callback finishes early, `safeTiming` waits for the remaining duration. If it throws, the error is re-thrown after the delay. ```ts import { safeTiming } from '@adonisjs/core/helpers' // Both paths (user exists or not) take the same minimum time return safeTiming(200, async () => { const user = await User.findBy('email', email) if (user) await sendResetEmail(user) return { message: 'If this email exists, you will receive a reset link.' } }) ``` The callback receives a `timing` object with a `returnEarly` method to skip the delay. This is useful when you want constant time on failure, but fast responses on success. ```ts import { safeTiming } from '@adonisjs/core/helpers' return safeTiming(200, async (timing) => { const token = await Token.findBy('value', request.header('x-api-key')) if (token) { timing.returnEarly() return token.owner } throw new UnauthorizedException() }) ``` ## compose The `compose` helper allows you to use TypeScript class mixins with a cleaner API. Following is an example of mixin usage without the `compose` helper. ```ts class User extends UserWithAttributes(UserWithAge(UserWithPassword(UserWithEmail(BaseModel)))) {} ``` Following is an example with the `compose` helper. - There is no nesting. - The order of mixins is from (left to right/top to bottom). Whereas earlier, it was inside out. ```ts import { compose } from '@adonisjs/core/helpers' class User extends compose( BaseModel, UserWithEmail, UserWithPassword, UserWithAge, UserWithAttributes ) {} ``` ## base64 Utility methods to base64 encode and decode values. ```ts import { base64 } from '@adonisjs/core/helpers' base64.encode('hello world') // aGVsbG8gd29ybGQ= ``` Like the `encode` method, you can use the `urlEncode` to generate a base64 string safe to pass in a URL. The `urlEncode` method performs the following replacements. - Replace `+` with `-`. - Replace `/` with `_`. - And remove the `=` sign from the end of the string. ```ts base64.urlEncode('hello world') // aGVsbG8gd29ybGQ ``` You can use the `decode` and the `urlDecode` methods to decode a previously encoded base64 string. ```ts base64.decode(base64.encode('hello world')) // hello world base64.urlDecode(base64.urlEncode('hello world')) // hello world ``` The `decode` and the `urlDecode` methods return `null` when the input value is an invalid base64 string. You can turn on the `strict` mode to raise an exception instead. ```ts base64.decode('hello world') // null base64.decode('hello world', 'utf-8', true) // raises exception ``` ## fsReadAll Get a list of all the files from a directory. The method recursively fetches files from the main and the sub-folders. The dotfiles are ignored implicitly. ```ts import { fsReadAll } from '@adonisjs/core/helpers' const files = await fsReadAll(new URL('./config', import.meta.url), { pathType: 'url' }) await Promise.all(files.map((file) => import(file))) ``` You can also pass the options along with the directory path as the second argument. ```ts type Options = { ignoreMissingRoot?: boolean filter?: (filePath: string, index: number) => boolean sort?: (current: string, next: string) => number pathType?: 'relative' | 'unixRelative' | 'absolute' | 'unixAbsolute' | 'url' } const options: Partial = {} await fsReadAll(location, options) ``` | Argument | Description | |------------|------------| | `ignoreMissingRoot` | By default, an exception is raised when the root directory is missing. Setting `ignoreMissingRoot` to true will not result in an error, and an empty array is returned. | | `filter` | Define a filter to ignore certain paths. The method is called on the final list of files. | | `sort` | Define a custom method to sort file paths. By default, the files are sorted using natural sort. | | `pathType` | Define how to return the collected paths. By default, OS-specific relative paths are returned. If you want to import the collected files, you must set the`pathType = 'url'` | ## fsImportAll The `fsImportAll` method imports all the files recursively from a given directory and sets the exported value from each module on an object. ```ts import { fsImportAll } from '@adonisjs/core/helpers' const collection = await fsImportAll(new URL('./config', import.meta.url)) console.log(collection) ``` - Collection is an object with a tree of key-value pairs. - The key is the nested object created from the file path. - Value is the exported values from the module. Only the default export is used if a module has both `default` and `named` exports. The second param is the option to customize the import behavior. ```ts type Options = { ignoreMissingRoot?: boolean filter?: (filePath: string, index: number) => boolean sort?: (current: string, next: string) => number transformKeys? (keys: string[]) => string[] } const options: Partial = {} await fsImportAll(location, options) ``` | Argument | Description | |------------|------------| | `ignoreMissingRoot` | By default, an exception is raised when the root directory is missing. Setting `ignoreMissingRoot` to true will not result in an error, and an empty object will be returned. | | `filter` | Define a filter to ignore certain paths. By default, only files ending with `.js`, `.ts`, `.json`, `.cjs`, and `.mjs` are imported. | | `sort` | Define a custom method to sort file paths. By default, the files are sorted using natural sort. | | `transformKeys` | Define a callback method to transform the keys for the final object. The method receives an array of nested keys and must return an array. | ## String builder The `StringBuilder` class offers a fluent API to perform transformations on a string value. You may get an instance of string builder using the `string.create` method. ```ts import string from '@adonisjs/core/helpers/string' const value = string .create('userController') .removeSuffix('controller') // user .plural() // users .snakeCase() // users .suffix('_controller') // users_controller .ext('ts') // users_controller.ts .toString() ``` ## Message builder The `MessageBuilder` class offers an API to serialize JavaScript data types with an expiry and purpose. You can either store the serialized output in safe storage like your application database or encrypt it (to avoid tampering) and share it publicly. In the following example, we serialize an object with the `token` property and set its expiry to be `1 hour`. ```ts import { MessageBuilder } from '@adonisjs/core/helpers' const builder = new MessageBuilder() const encoded = builder.build( { token: string.random(32), }, '1 hour', 'email_verification' ) /** * { * "message": { * "token":"GZhbeG5TvgA-7JCg5y4wOBB1qHIRtX6q" * }, * "purpose":"email_verification", * "expiryDate":"2022-10-03T04:07:13.860Z" * } */ ``` Once you have the JSON string with the expiry and the purpose, you can encrypt it (to prevent tampering) and share it with the client. During the token verification, you can decrypt the previously encrypted value and use the `MessageBuilder` to verify the payload and convert it to a JavaScript object. ```ts import { MessageBuilder } from '@adonisjs/core/helpers' const builder = new MessageBuilder() const decoded = builder.verify(value, 'email_verification') if (!decoded) { return 'Invalid payload' } console.log(decoded.token) ``` ## Secret The `Secret` class lets you hold sensitive values within your application without accidentally leaking them inside logs and console statements. For example, the `appKey` value defined inside the `config/app.ts` file is an instance of the `Secret` class. If you try to log this value to the console, you will see `[redacted]` and not the original value. For demonstration, let's fire up a REPL session and try it. ```sh node ace repl ``` ```sh > (js) config = await import('./config/app.js') # [Module: null prototype] { // highlight-start # appKey: [redacted], // highlight-end # http: { # } # } ``` ```sh > (js) console.log(config.appKey) # [redacted] ``` You can call the `config.appKey.release` method to read the original value. The purpose of the Secret class is not to prevent your code from accessing the original value. Instead, it provides a safety net from exposing sensitive data inside logs. ### Using the Secret class You can wrap custom values inside the Secret class as follows. ```ts import { Secret } from '@adonisjs/core/helpers' const value = new Secret('some-secret-value') console.log(value) // [redacted] console.log(value.release()) // some-secret-value ``` ## Data-types detection We export the [@sindresorhus/is](https://github.com/sindresorhus/is) module from the `helpers/is` import path, and you may use it to perform the type detection in your apps. ```ts import is from '@adonisjs/core/helpers/is' is.object({}) // true is.object(null) // false ``` --- # Types helpers ## InferRouteParams Infer params of a route pattern. The params must be defined as per the AdonisJS routing syntax. ```ts import type { InferRouteParams } from '@adonisjs/core/helpers/types' InferRouteParams<'/users'> // {} InferRouteParams<'/users/:id'> // { id: string } InferRouteParams<'/users/:id?'> // { id?: string } InferRouteParams<'/users/:id/:slug?'> // { id: string; slug?: string } InferRouteParams<'/users/:id.json'> // { id: string } InferRouteParams<'/users/*'> // { '*': string[] } InferRouteParams<'/posts/:category/*'> // { 'category': string; '*': string[] } ``` ## Prettify Prettifies the complex TypeScript types to a simplified type for a better viewing experience. For example: ```ts import type { Prettify } from '@adonisjs/core/helpers/types' import type { ExtractDefined, ExtractUndefined } from '@adonisjs/core/helpers/types' type Values = { username: string | undefined email: string fullName: string | undefined age: number | undefined } // When not using prettify helper type WithUndefinedOptional = { [K in ExtractDefined]: Values[K] } & { [K in ExtractUndefined]: Values[K] } // When using prettify helper type WithUndefinedOptionalPrettified = Prettify< { [K in ExtractDefined]: Values[K] } & { [K in ExtractUndefined]: Values[K] } > ``` ## Primitive Union of primitive types. It includes `null | undefined | string | number | boolean | symbol | bigint` ```ts import type { Primitive } from '@adonisjs/core/helpers/types' function serialize( values: | Primitive | Record | Primitive[] | Record[] ) {} ``` ## OneOrMore Specify a union that accepts either `T` or `T[]`. ```ts import type { OneOrMore } from '@adonisjs/core/helpers/types' import type { Primitive } from '@adonisjs/core/helpers/types' function serialize( values: OneOrMore | OneOrMore> ) {} ``` ## Constructor Represent a class constructor. The `T` refers to the class instance properties, and `Arguments` refers to the constructor arguments. ```ts import type { Constructor } from '@adonisjs/core/helpers/types' function make(Klass: Constructor, ...args: Args) { return new Klass(...args) } ``` ## AbstractConstructor Represent a class constructor that could also be abstract. The `T` refers to the class instance properties, and `Arguments` refers to the constructor arguments. ```ts import type { AbstractConstructor } from '@adonisjs/core/helpers/types' function log(Klass: AbstractConstructor, ...args: Args) {} ``` ## LazyImport Represent a function that lazily imports a module with `export default`. ```ts import type { LazyImport, Constructor } from '@adonisjs/core/helpers/types' function middleware(list: LazyImport>[]) {} ``` ## UnWrapLazyImport Unwrap the default export of a `LazyImport` function. ```ts import type { LazyImport, UnWrapLazyImport } from '@adonisjs/core/helpers/types' type Middleware = LazyImport> type MiddlewareClass = UnWrapLazyImport ``` ## NormalizeConstructor Normalizes the constructor arguments of a class for use with mixins. The helper is created to work around [TypeScript issue#37142](https://github.com/microsoft/TypeScript/issues/37142). ```ts // title: Usage without NormalizeConstructor class Base {} function DatesMixin(superclass: TBase) { // A mixin class must have a constructor with a single rest parameter of type 'any[]'. ts(2545) return class HasDates extends superclass { // ❌ ^^ declare createdAt: Date declare updatedAt: Date } } // Base constructors must all have the same return type.ts(2510) class User extends DatesMixin(Base) {} // ❌ ^^ ``` ```ts // title: Using NormalizeConstructor import type { NormalizeConstructor } from '@adonisjs/core/helpers/types' class Base {} function DatesMixin>(superclass: TBase) { return class HasDates extends superclass { declare createdAt: Date declare updatedAt: Date } } class User extends DatesMixin(Base) {} ``` ## Opaque Define an opaque type to distinguish between similar properties. ```ts import type { Opaque } from '@adonisjs/core/helpers/types' type Username = Opaque type Password = Opaque function checkUser(_: Username) {} // ❌ Argument of type 'string' is not assignable to parameter of type 'Opaque'. checkUser('hello') // ❌ Argument of type 'Opaque' is not assignable to parameter of type 'Opaque'. checkUser('hello' as Password) checkUser('hello' as Username) ``` ## UnwrapOpaque Unwrap the value from an opaque type. ```ts import type { Opaque, UnwrapOpaque } from '@adonisjs/core/helpers/types' type Username = Opaque type Password = Opaque type UsernameValue = UnwrapOpaque // string type PasswordValue = UnwrapOpaque // string ``` ## ExtractFunctions Extract all the functions from an object. Optionally specify a list of methods to ignore. ```ts import type { ExtractFunctions } from '@adonisjs/core/helpers/types' class User { declare id: number declare username: string create() {} update(_id: number, __attributes: any) {} } type UserMethods = ExtractFunctions // 'create' | 'update' ``` You may use the `IgnoreList` to ignore methods from a known parent class ```ts import type { ExtractFunctions } from '@adonisjs/core/helpers/types' class Base { save() {} } class User extends Base { declare id: number declare username: string create() {} update(_id: number, __attributes: any) {} } type UserMethods = ExtractFunctions // 'create' | 'update' type UserMethodsWithParent = ExtractFunctions> // 'create' | 'update' ``` ## AreAllOptional Check if all the top-level properties of an object are optional. ```ts import type { AreAllOptional } from '@adonisjs/core/helpers/types' AreAllOptional<{ id: string; name?: string }> // false AreAllOptional<{ id?: string; name?: string }> // true ``` ## ExtractUndefined Extract properties that are `undefined` or are a union with `undefined` values. ```ts import type { ExtractUndefined } from '@adonisjs/core/helpers/types' type UndefinedProperties = ExtractUndefined<{ id: string; name: string | undefined }> ``` ## ExtractDefined Extract properties that are not `undefined` nor is a union with `undefined` values. ```ts import type { ExtractDefined } from '@adonisjs/core/helpers/types' type UndefinedProperties = ExtractDefined<{ id: string; name: string | undefined }> ``` ## AsyncOrSync Define a union with the value or a `PromiseLike` of the value. ```ts import type { AsyncOrSync } from '@adonisjs/core/helpers/types' function log(fetcher: () => AsyncOrSync<{ id: number }>) { const { id } = await fetcher() } ``` ---