<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom">
  <channel>
    <title>Adocasts - AdonisJS Screencasts &amp; Lessons</title>
    <link>https://adocasts.com</link>
    <description>Recent content from Adocasts - Learn AdonisJS, NodeJS, JavaScript and more through in-depth lesson screencasts.</description>
    <lastBuildDate>Mon, 26 Oct 2026 15:21:14 +0000</lastBuildDate>
    <docs>https://validator.w3.org/feed/docs/rss2.html</docs>
    <generator>manual</generator>
    <language>en</language>
    <image>
      <title>Adocasts - AdonisJS Screencasts &amp; Lessons</title>
      <url>https://adocasts.com/android-chrome-512x512.png</url>
      <link>https://adocasts.com</link>
    </image>
    <copyright>All rights reserved 2026, Adocasts.com</copyright>
    <category>Development</category>
    <category>NodeJS</category>
    <category>AdonisJS</category>
    <category>JavaScript</category>
    <category>LucidORM</category>
    <category>EdgeJS</category>
    <atom:link href="https://adocasts.com/rss" rel="self" type="application/rss+xml"/>

      <item>
        <title><![CDATA[Saying Goodbye to Adocasts Plus]]></title>
        <link>https://adocasts.com/news/saying-goodbye-to-adocasts-plus</link>
        <guid>https://adocasts.com/news/saying-goodbye-to-adocasts-plus</guid>
        <pubDate>Fri, 19 Jun 2026 15:00:00 +0000</pubDate>
        <description><![CDATA[Adocasts Plus is being sunset, effective immediately. Here's what that means, why I'm making the call, and how prorated refunds will work.]]></description>
        <content:encoded><![CDATA[<p>I've got some bittersweet news to share. After a lot of internal debating and thought, as of today, Adocasts Plus is being sunset effective immediately.</p><p>Adocasts has been a huge part of my life for the past five years, and the people who've subscribed to Adocasts Plus have made it possible to keep the lights on and keep producing content. So, before anything else, thank you! I mean that dearly. You put your trust and faith in me, and I don't take that lightly.</p><p>You might remember a post I put up a couple of months back where I said I was taking a break, and went out of my way to say I wasn't quitting. I meant that when I wrote it. The plan was a roughly 90-day reset to recharge and figure out a way forward. The truth is I never actually started that break. Instead, what happened is I unintentionally slowed way down on releasing new Let's Learn AdonisJS 7 lessons, and I ended up spending that quieter stretch weighing my options for the road ahead. That slow drift, more than any clean reset, is really what brought me here. So, as much as it stings to walk back what I said then, here we are.</p><p>Let me walk you through what this actually means, why I landed here, and most importantly, how refunds will work for current members.</p><h2>Why I'm Making This Call</h2><p>In truth, there isn't one single reason but several reasons that join together. I also can't overstate how much I've teetered on this decision, and I continue to do so to some extent even now.</p><p>The biggest piece is time. I've always run Adocasts part-time, alongside a full-time job, and producing long-form content the way I want to takes a lot of hours. As touched on in my last post, I just haven't had the hours or effort to give, and putting out work I'm not proud of isn't something I'm willing to do.</p><p>There's also a bigger question hanging over long-form content in general. The way people learn is shifting, and I've spent a long time wondering whether the long, detailed format Adocasts is built on is something I can sustain into the future. I don't have a clear answer here, but it's a real concern and factored in.</p><p>The last piece is more personal to where I am in my career. Over the past couple of months, AI has become a much bigger part of how I work, more than I expected when I wrote my last post. This has also shifted my day-to-day role a decent bit. Instead of sitting hands-on in the editor, I'm finding myself directing and managing AI more often than not. It feels a little off to keep teaching from a workflow I've largely moved on from, like I'd be sharing AI's process more than my own.</p><p>For a while, I seriously considered pivoting Adocasts toward something more interactive with hands-on learning rather than watching and reading. I still think that's a genuinely exciting direction, and it may be something I come back to one day. However, building something like that properly is an enormous amount of work, and I couldn't see a realistic way to do it side by side while also keeping new Adocasts content flowing. Trying to do both at the same time would mean doing both poorly.</p><p>So after circling this decision over and over again, I've concluded that it's time to stop.</p><h2>The Content Isn't Going Anywhere</h2><p>Sunsetting Adocasts Plus doesn't mean the content is going to disappear. The whole point of Adocasts was to help people learn AdonisJS, and locking the library away the moment I step back would have undercut the entire reason it existed. So, I'm happy to announce that the entire Adocasts library will be free to all, and this will go into effect here in the next day or two.</p><p>There's one piece of unfinished business I do plan to see through, though. I still want to get the Let's Learn AdonisJS 7 series to a proper finished state. I'll be trimming it down from what I currently have planned, but I'd rather land it somewhere that feels complete than leave it hanging. Those lessons will keep coming as a trickle, the same slow pace as anything else from here on, but they will come.</p><p>Beyond wrapping that series, you may still see the occasional new lesson pop up from time to time. I'm not committing to a schedule, and I'm not promising anything regular, but if I have something worth sharing, it'll land, free, on Adocasts.</p><h2>How Refunds Will Work</h2><p>Since the content everyone paid for is becoming free to all, it didn't sit right with me to keep anyone's money for access that's no longer exclusive. So, all active subscriptions have been canceled, and you won't be charged again. I'm also issuing prorated refunds to Adocasts Plus members, and yes, that includes forever purchasers as well.</p><p>The refund is prorated based on how much of your access period you've used: roughly the days you've had access divided by the length of your plan. Monthly and annual members are prorated against their plan length. For forever purchasers, I figured it would be most fair to treat it the same way, refunding the unused portion of your access. I'll be measuring it against a three-year window, since "forever" has no natural end date to calculate against, and three years is what the price was originally based on. To be clear, this isn't me buying back access; the library stays up and free, and you keep everything you always had. It's me returning the slice of a long-term plan that, in fairness, went unused. The math is applied the same way across every tier, so it's as fair and even-handed as I can make it.</p><p>A few notes on the mechanics and timing:</p><ul><li><p>Refunds go back automatically to the original payment method you used. You don't need to do anything or reach out; I'll be processing these one-by-one on my end.</p></li><li><p>Most refunds should land within a few business days, though your bank or card issuer can sometimes take a little longer to post it.</p></li><li><p>If your original card or account has since been closed, the refund may bounce back to me. If that happens, I'll reach out directly to sort out another way to get it to you.</p></li></ul><p>If something looks off with your refund once it arrives, please get in touch with me via email at&nbsp;<a target="_blank" rel="nofollow noopener noreferrer" href="mailto:tom@adocasts.com">tom@adocasts.com,</a>&nbsp;and I'll make it right.</p><h2>Thank You</h2><p>Adocasts mattered a lot to me, and the people who supported it mattered more. Watching folks learn AdonisJS through something I built has been one of the most rewarding things I've ever done.</p><p>I especially want to thank Harminder Virk and the entire AdonisJS Core Team for being super welcoming and supportive of Adocasts and me. Words can't express how appreciative I am of that. I genuinely think AdonisJS is&nbsp;<strong><em>the</em></strong>&nbsp;option in NodeJS development, and I've been nothing but happy to watch its popularity rise.</p><p>The doors to Adocasts aren't locked, the library is free and staying up, and I'm not ruling out a comeback down the road, maybe even in a new format. In the meantime, thank you all very much for being a part of this journey.</p>]]></content:encoded>
      </item>
      <item>
        <title><![CDATA[What's New in AdonisJS - Week of May 31, 2026]]></title>
        <link>https://adocasts.com/news/whats-new-in-adonisjs-week-of-may-31-2026</link>
        <guid>https://adocasts.com/news/whats-new-in-adonisjs-week-of-may-31-2026</guid>
        <pubDate>Sat, 06 Jun 2026 12:10:00 +0000</pubDate>
        <description><![CDATA[A rundown of what the AdonisJS team shipped the week of May 30 - June 6, 2026, including new mail transports, encryption fixes, and supply-chain hardening.]]></description>
        <content:encoded><![CDATA[<p><em>The following is AI generated based on GitHub activity across the AdonisJS repositories.</em></p><hr><h2><a target="_blank" rel="nofollow noopener noreferrer" class="underline underline underline-offset-2 decoration-1 decoration-current/40 hover:decoration-current focus:decoration-current" href="https://github.com/adonisjs">Releases</a></h2><p>Here's a quick look at what dropped this week:</p><ul><li><p><code>@adonisjs/core</code><a target="_blank" rel="nofollow noopener noreferrer" class="underline underline underline-offset-2 decoration-1 decoration-current/40 hover:decoration-current focus:decoration-current" href="https://github.com/adonisjs/core/releases/tag/v7.3.4"> v7.3.4</a> — a backward-compatibility fix for the Legacy encryption driver</p></li><li><p><code>@adonisjs/mail</code><a target="_blank" rel="nofollow noopener noreferrer" class="underline underline underline-offset-2 decoration-1 decoration-current/40 hover:decoration-current focus:decoration-current" href="https://github.com/adonisjs/mail/releases/tag/v10.3.0"> v10.3.0</a> — new Postmark and Cloudflare Email Service transports, plus a few transport fixes</p></li><li><p><code>@adonisjs/i18n</code><a target="_blank" rel="nofollow noopener noreferrer" class="underline underline underline-offset-2 decoration-1 decoration-current/40 hover:decoration-current focus:decoration-current" href="https://github.com/adonisjs/i18n/releases/tag/v3.0.1"> v3.0.1</a> — a fix for <code>confirmed</code> validation rule translation lookups</p></li><li><p><code>edge-markdown</code><a target="_blank" rel="nofollow noopener noreferrer" class="underline underline underline-offset-2 decoration-1 decoration-current/40 hover:decoration-current focus:decoration-current" href="https://github.com/edge-js/edge-markdown/releases/tag/v1.10.2"> v1.10.2</a> — the same <code>dashCase</code> bug fixed on the Edge side</p></li></ul><hr><h2><a target="_blank" rel="nofollow noopener noreferrer" class="underline underline underline-offset-2 decoration-1 decoration-current/40 hover:decoration-current focus:decoration-current" href="https://github.com/adonisjs/mail/releases/tag/v10.3.0">Native Postmark and Cloudflare Email Transports</a></h2><p>The big new feature this week comes from <code>@adonisjs/mail</code> v10.3.0, which ships two new mail transports: Postmark and Cloudflare Email Service.</p><p>Postmark is a popular transactional email provider that, up until now, required a workaround to use with AdonisJS. You can wire it up in <code>config/mail.ts</code> like this:</p><pre><code class="language-typescript">// config/mail.ts
import env from '#start/env'
import { defineConfig, transports } from '@adonisjs/mail'

export default defineConfig({
  default: 'postmark',

  mailers: {
    postmark: transports.postmark({
      key: env.get('POSTMARK_API_KEY'),
      baseUrl: 'https://api.postmarkapp.com/email',
    }),
  },
})</code></pre><p>The Cloudflare Email Service transport is also in the codebase, but it's intentionally hidden from <code>defineConfig</code> for now. Cloudflare's service is still in beta, so the team is laying the groundwork ahead of time without surfacing it as a stable option. That's a nice pattern — you'll be able to opt in as soon as it's ready without waiting on the package to catch up.</p><hr><h2><a target="_blank" rel="nofollow noopener noreferrer" class="underline underline underline-offset-2 decoration-1 decoration-current/40 hover:decoration-current focus:decoration-current" href="https://github.com/adonisjs/core/releases/tag/v7.3.4">Encryption Migration Fix in Core</a></h2><p>If you've migrated an application from AdonisJS v5 to v6 or v7 and used the Legacy encryption driver to preserve compatibility with old encrypted data, this patch is for you.</p><p>The bug was subtle: the Legacy driver was generating initialization vectors (IVs) as raw 16-byte buffers instead of 16-character strings. That mismatch broke decryption of data that was originally encrypted by the old <code>@adonisjs/encryption</code> package. In practice, this means anything stored in your database or sessions that was encrypted under v5 couldn't be decrypted correctly in v7 — which is a pretty significant migration-breaker.</p><p>The fix makes the Legacy driver a true drop-in replacement for the old encrypter. If you hit this during a migration and put it on the backburner, now's a good time to revisit it.</p><hr><h2><a target="_blank" rel="nofollow noopener noreferrer" class="underline underline underline-offset-2 decoration-1 decoration-current/40 hover:decoration-current focus:decoration-current" href="https://github.com/adonisjs/lucid/pull/1180">make:model Flag Collision Fixed in Lucid</a></h2><p>This one's a small but genuinely annoying bug if you've run into it. When using <code>node ace make:model</code> with the <code>-mc</code> flag shorthand, both <code>--transformer</code> and <code>--controller</code> were aliased to <code>-c</code>. That meant running:</p><pre><code class="language-bash">node ace make:model -mc Price</code></pre><p>would silently generate the model, migration, <em>and</em> transformer — but <strong>not</strong> the controller. And because <code>-c</code> was claimed by transformer, there was no way to combine <code>--controller</code> with the other flags at all.</p><p>The fix is simple: the transformer shorthand moves from <code>-c</code> to <code>-t</code>, so <code>-c</code> reliably means controller again. The updated flag layout looks like this:</p><pre><code class="language-bash"># Before: -c was claimed by both transformer and controller
node ace make:model -mc Price        # model + migration + transformer (no controller)

# After: transformer now uses -t, so -c reliably means controller
node ace make:model -mtc Price       # model + migration + transformer + controller</code></pre><p>Worth noting: this is technically a breaking change if anything in your scripts was explicitly relying on <code>-c</code> as the transformer shorthand, so keep that in mind when upgrading.</p><hr><h2><a target="_blank" rel="nofollow noopener noreferrer" class="underline underline underline-offset-2 decoration-1 decoration-current/40 hover:decoration-current focus:decoration-current" href="https://github.com/adonisjs/i18n/releases/tag/v3.0.1">dashCase Bug Fixes in i18n and edge-markdown</a></h2><p>Two separate packages got fixes for the same underlying issue this week, which makes it a fun one to explain.</p><p>The <code>dashCase</code> utility — which converts things like <code>fieldName</code> into <code>field-name</code> — has a quirk with alphanumeric strings like <code>h2</code>. When passed through <code>dashCase</code>, <code>h2</code> was being rewritten to <code>h-2</code>, inserting a hyphen between the letter and the number.</p><h3><a target="_blank" rel="nofollow noopener noreferrer" class="underline underline underline-offset-2 decoration-1 decoration-current/40 hover:decoration-current focus:decoration-current" href="https://github.com/adonisjs/i18n/releases/tag/v3.0.1">i18n: confirmed validation messages</a></h3><p>In <code>@adonisjs/i18n</code>, this bug bit the <code>confirmed</code> validation rule. If you had a field named something like <code>h2</code>, the translation lookup would look for the key <code>h-2</code> instead of <code>h2</code>, and never find a match. Your custom translation strings for that field just wouldn't be picked up.</p><p>The fix corrects the transform so original field names resolve to the right translation keys. As a bonus, <code>translateField</code> is now also overridable when extending <code>I18nMessagesProvider</code>, which gives you more flexibility if you're doing anything custom with the messages provider.</p><h3><a target="_blank" rel="nofollow noopener noreferrer" class="underline underline underline-offset-2 decoration-1 decoration-current/40 hover:decoration-current focus:decoration-current" href="https://github.com/edge-js/edge-markdown/releases/tag/v1.10.2">edge-markdown: overriding heading components</a></h3><p>The same bug showed up in <code>edge-markdown</code>. If you wanted to override how markdown headings render by creating a custom Edge component — say, <code>h2.edge</code> — it simply wouldn't work. The component discovery step was converting <code>h2</code> to <code>h-2</code>, so your file was never matched to the element.</p><p>After the fix, you can define a component like this and it'll be picked up correctly:</p><pre><code class="language-edge">// resources/views/components/h2.edge
&lt;h2 class="text-2xl font-bold"&gt;
  {{ await $slots.default() }}
&lt;/h2&gt;</code></pre><p>Super handy if you're using edge-markdown and want consistent heading styles across your app without resorting to global CSS overrides.</p><hr><h2><a target="_blank" rel="nofollow noopener noreferrer" class="underline underline underline-offset-2 decoration-1 decoration-current/40 hover:decoration-current focus:decoration-current" href="https://github.com/adonisjs/mail/releases/tag/v10.3.0">Resend and Brevo Transport Fixes</a></h2><p>Also bundled into the <code>@adonisjs/mail</code> v10.3.0 release: fixes for the Resend and Brevo transports. Both weren't forwarding custom and list headers on outgoing messages, and there were some edge cases where attachment content wasn't resolving correctly.</p><p>If you're using either of those transports, upgrading to v10.3.0 will grab these fixes along with everything else in the release.</p><hr><h2><a target="_blank" rel="nofollow noopener noreferrer" class="underline underline underline-offset-2 decoration-1 decoration-current/40 hover:decoration-current focus:decoration-current" href="https://github.com/adonisjs/transmit/pull/44">New Docs: Protecting Subscription Routes and Testing Broadcasts</a></h2><p>The <code>@adonisjs/transmit</code> README got a meaningful update with three previously-missing sections:</p><ul><li><p>How to protect subscription routes with auth middleware</p></li><li><p>A full client-side guide covering <code>beforeSubscribe</code> and Bearer token setup</p></li><li><p>A Japa testing section for asserting broadcasts and checking that unauthenticated subscriptions return 401s</p></li></ul><p>If you're using Transmit for server-sent events and felt like the docs were leaving some gaps, this PR fills a lot of them in.</p><hr><h2><a target="_blank" rel="nofollow noopener noreferrer" class="underline underline underline-offset-2 decoration-1 decoration-current/40 hover:decoration-current focus:decoration-current" href="https://github.com/adonisjs/v7-docs/pull/23">New Docs: Database Assertions for Testing</a></h2><p>The v7 docs also got a new section on database assertions — useful for anyone writing Japa tests that need to verify database state after an operation. If that workflow is part of your test suite, it's worth a look.</p><hr><h2>Supply-Chain Security Hardening</h2><p>Finally, a coordinated set of security improvements landed across <code>@adonisjs/core</code>, <code>@adonisjs/i18n</code>, and <code>@adonisjs/mail</code> this week. None of it changes runtime behavior, but it's meaningful work:</p><ul><li><p>npm Trusted Publishing via OIDC</p></li><li><p>Pinned GitHub Action SHAs</p></li><li><p><code>npm install --ignore-scripts</code> for dependency installation</p></li><li><p><code>npm audit signatures</code> verification</p></li><li><p>Dependabot enabled for GitHub Actions</p></li></ul><p>This kind of infrastructure work is easy to overlook because it's invisible when it's working — but it meaningfully tightens the supply-chain posture of the packages most AdonisJS apps depend on directly. Good to see the team giving it attention.</p><hr><p>So, all in all, a solid maintenance week for the ecosystem. The encryption migration fix and the <code>dashCase</code> bugs are probably the most impactful if you've been quietly dealing with either of them, and Postmark support is a welcome addition for anyone on that stack. <code>vinejs</code>, <code>flydrive-js</code>, <code>japa</code>, and Julien-R44's <code>tuyau</code>, <code>hot-hook</code>, and <code>verrou</code> were all quiet this week — so if those are your main dependencies, no action needed.</p>]]></content:encoded>
      </item>
      <item>
        <title><![CDATA[Understanding by Doing, Clean Slate]]></title>
        <link>https://adocasts.com/lessons/understanding-by-doing-clean-slate</link>
        <guid>https://adocasts.com/lessons/understanding-by-doing-clean-slate</guid>
        <pubDate>Fri, 05 Jun 2026 12:00:00 +0000</pubDate>
        <description><![CDATA[Learn authentication from scratch in AdonisJS. Remove starter kit auth and rebuild authentication features manually.]]></description>
        <content:encoded><![CDATA[<p>As you've probably noticed, the AdonisJS starter kit came with authentication working out of the box. I firmly believe in learning by doing, so in this lesson, all we're going to do is remove the route-level portions of our authentication that've been provided by the starter kit. We'll redo it ourselves so that we're familiar with everything that's going on.</p><p>So, to start, we can delete the route definitions that've been provided to us in our <code>start/routes.ts</code> file. You should just be left with the below after removing.</p><pre><code class="language-ts">// start/routes.ts
import { controllers } from '#generated/controllers'
import { middleware } from '#start/kernel'
import router from '@adonisjs/core/services/router'

router.where('id', router.matchers.number())

router.on('/').render('pages/home').as('home')

router.on('/terms').render('pages/terms')

router
  .resource('challenges', controllers.Challenges)
  .use(['index', 'edit', 'create'], middleware.requestLogger())</code></pre><p>Next, we can remove the <code>new_account_controller</code>, <code>session_controller</code>, and <code>user</code> validator.</p><pre><code class="language-bash">rm app/controllers/new_account_controller.ts &amp;&amp; 
rm app/controllers/session_controller.ts &amp;&amp;
rm app/validators/user.ts</code></pre><p>Perfect! Now all that remains are config-level things that we'll cover as we go through implementing authentication. Then, we've already learned through everything happening in our auth pages, so we can leave those as well and just do a quick refresher.</p><p>Starting with our login page.</p><pre><code class="language-edge">// resources/views/pages/auth/login.edge
@layout()
  &lt;div class="form-container"&gt;
    &lt;div&gt;
      &lt;h1&gt; Login &lt;/h1&gt;
      &lt;p&gt;
        Enter your email and password below to log in
      &lt;/p&gt;
    &lt;/div&gt;

    &lt;div&gt;
      @form({ route: 'session.store', method: 'POST' })
        &lt;div&gt;
          @field.root({ name: 'email' })
            @!field.label({ text: 'Email' })
            @!input.control({ type: 'email' })
            @!field.error()
          @end
        &lt;/div&gt;

        &lt;div&gt;
          @field.root({ name: 'password' })
            @!field.label({ text: 'Password' })
            @!input.control({ type: 'password' })
            @!field.error()
          @end
        &lt;/div&gt;

        &lt;div&gt;
          @!button({ text: 'Login', type: 'submit' })
        &lt;/div&gt;
      @end
    &lt;/div&gt;
  &lt;/div&gt;
@end</code></pre><p>For starters, we have the <code>form</code> component that accepts a <code>method</code>, <code>route</code>, <code>routeParams</code>, and <code>routeOptions</code> to build out the final <code>action</code> URL for the form's submission. This component will also add our <a target="_blank" rel="noopener nofollow" class="external-link" href="https://adocasts.com/lessons/form-basics-method-spoofing-and-csrf">CSRF</a> field when performing POST, PUT, PATCH, and DELETE requests.</p><p>Then, we have the field components that build out the input fields. The <code>root</code> injects the fields' context, which includes the input's name, old value from the previous submission, and error state. The <code>label</code>, <code>control</code>, and <code>error</code> then use this context to build out their applicable section of the input.</p><p>Finally, the button adds a submit button to our form!</p><p>Then, the only difference in our signup page is the addition of two fields.</p><pre><code class="language-edge">// resources/views/pages/auth/signup.edge
@layout()
  &lt;div class="form-container"&gt;
    &lt;div&gt;
      &lt;h1&gt; Signup &lt;/h1&gt;
      &lt;p&gt;
        Enter your details below to create your account
      &lt;/p&gt;
    &lt;/div&gt;

    &lt;div&gt;
      @form({ route: 'new_account.store', method: 'POST' })
        &lt;div&gt;
          @field.root({ name: 'fullName' })
            @!field.label({ text: 'Full name' })
            @!input.control()
            @!field.error()
          @end
        &lt;/div&gt;

        &lt;div&gt;
          @field.root({ name: 'email' })
            @!field.label({ text: 'Email' })
            @!input.control({ type: 'email', autocomplete: 'email' })
            @!field.error()
          @end
        &lt;/div&gt;

        &lt;div&gt;
          @field.root({ name: 'password' })
            @!field.label({ text: 'Password' })
            @!input.control({ type: 'password', autocomplete: 'new-password' })
            @!field.error()
          @end
        &lt;/div&gt;

        &lt;div&gt;
          @field.root({ name: 'passwordConfirmation' })
            @!field.label({ text: 'Confirm password' })
            @!input.control({ type: 'password', autocomplete: 'new-password' })
            @!field.error()
          @end
        &lt;/div&gt;

        &lt;div&gt;
          @!button({ text: 'Sign up', type: 'submit' })
        &lt;/div&gt;
      @end
    &lt;/div&gt;
  &lt;/div&gt;
@end</code></pre><p>The two added fields here are for the user's <code>fullName</code> and then <code>passwordConfirmation</code> to ensure the user knows the password they've entered. An alternative here would be a single password field with a show/hide value toggle.</p><p>Lastly, we have in our header partial an if/else for our authentication.</p><pre><code class="language-edge">// resource/views/partials/header.edge
&lt;header&gt;
  &lt;div&gt;
    &lt;div&gt;
      @link({ route: 'home', text: 'Home' })
        {{-- svg ... --}}
      @end
    &lt;/div&gt;
    &lt;div&gt;
      &lt;nav&gt;
        @if(auth.user)
          @!avatar({ initials: auth.user.initials })
          @form({ route: 'session.destroy', method: 'POST' })
            @!button({ text: 'Logout' })
          @end
        @else
          @!link({ route: 'new_account.create', text: 'Signup' })
          @!link({ route: 'session.create', text: 'Login' })
        @end
      &lt;/nav&gt;
    &lt;/div&gt;
  &lt;/div&gt;
&lt;/header&gt;</code></pre><p>If the user is logged in and the user has been populated for the request, <code>auth.user</code> will contain the authenticated user. So, when that is truthy, we'll show an avatar with the user's initials and a form to logout.</p><p>Note that the logout form sends a POST request. This is done to prevent <a target="_blank" rel="noopener nofollow" class="external-link" href="https://docs.adonisjs.com/guides/security/securing-ssr-applications">CSRF attacks</a>. Someone could force users to logout through malicious means if we were to use a GET request. Plus, among other things, it also helps protect against a user accidentally logging out due to pre-fetching. Pre-fetching is when a page is eagerly loaded before the user actually clicks the link.</p><p>If the user is not logged in, links to signup or login will be shown. Now, since we've deleted these routes, any pages referencing these routes will now throw a route not found error. Since our header is everywhere, we'll go ahead and just remove this section for now, and we'll add it back as we reimplement things.</p><pre><code class="language-edge">// resource/views/partials/header.edge
&lt;header&gt;
  &lt;div&gt;
    &lt;div&gt;
      @link({ route: 'home', text: 'Home' })
        {{-- svg ... --}}
      @end
    &lt;/div&gt;
    &lt;div&gt;
      &lt;nav&gt;
++        {{-- TODO --}}
      &lt;/nav&gt;
    &lt;/div&gt;
  &lt;/div&gt;
&lt;/header&gt;</code></pre><p>Great, let's get started!</p>]]></content:encoded>
      </item>
      <item>
        <title><![CDATA[Using the Authenticated User]]></title>
        <link>https://adocasts.com/lessons/using-the-authenticated-user</link>
        <guid>https://adocasts.com/lessons/using-the-authenticated-user</guid>
        <pubDate>Fri, 05 Jun 2026 12:00:00 +0000</pubDate>
        <description><![CDATA[Use the authenticated user in AdonisJS. Protect routes with auth middleware, attach creators to records, and verify ownership before mutations.]]></description>
        <content:encoded><![CDATA[<p>Now that users can sign up, sign in, and sign out, let's put them to work. In this lesson, we'll do three things: protect our challenge mutation routes so only authenticated users can reach them, replace the hardcoded <code>creatorId: 1</code> placeholder we left behind in our store method with the real authenticated user's id, and add ownership checks to our edit, update, and destroy methods so only the challenge's creator can modify or delete it.</p><h2>The Role of silent_auth_middleware</h2><p>Before we start gating things, it's worth understanding why <code>auth.user</code> is already available throughout our application, even on pages that don't require authentication, like the home page or the challenges list.</p><p>If we open up <code>app/middleware/silent_auth_middleware.ts</code> we'll find:</p><pre><code class="language-ts">// app/middleware/silent_auth_middleware.ts
import type { HttpContext } from '@adonisjs/core/http'
import type { NextFn } from '@adonisjs/core/types/http'

export default class SilentAuthMiddleware {
  async handle(ctx: HttpContext, next: NextFn) {
    await ctx.auth.check()
    return next()
  }
}</code></pre><p>And if we look in <code>start/kernel.ts</code>, this middleware is registered as a <a target="_blank" rel="noopener nofollow" class="external-link" href="https://adocasts.com/lessons/middleware-and-grouping-routes">router middleware</a>, meaning it runs on every request that hits a registered route.</p><pre><code class="language-ts">// start/kernel.ts
router.use([
  () =&gt; import('@adonisjs/core/bodyparser_middleware'),
  () =&gt; import('@adonisjs/session/session_middleware'),
  () =&gt; import('@adonisjs/shield/shield_middleware'),
  () =&gt; import('@adonisjs/auth/initialize_auth_middleware'),
  () =&gt; import('#middleware/silent_auth_middleware'),
])</code></pre><p>The key is that <code>ctx.auth.check()</code> silently attempts to authenticate the user using their session. If a valid session exists, it populates <code>ctx.auth.user</code> with the authenticated user. If not, it does nothing and moves on, no redirect, no error, just continues the request.</p><p>That is the key difference between <code>silent_auth_middleware</code> and the named <code>auth</code><a target="_blank" rel="noopener nofollow" class="external-link" href="https://docs.adonisjs.com/guides/auth/session-guard"> middleware</a> we've been working with. The <code>auth</code> middleware also checks authentication, but if the user isn't logged in it stops the request right there and redirects them to the login page. The silent variant just checks and keeps moving.</p><p>This is what allows our EdgeJS templates to reference <code>auth.user</code> everywhere, and why our header can conditionally show the avatar or login link without requiring a login. The user's auth state is always resolved by the time a request reaches our controllers or views.</p><h2>Protecting Challenge Routes</h2><p>Currently, anyone can access our create, edit, update, and destroy challenge routes whether they're authenticated or not. Let's fix that by adding the <code>auth</code> middleware to those actions on our challenges resource.</p><p>The <code>.use()</code> method on a resource accepts the route action names to target as its first argument, and the middleware to apply as its second. We can chain a second <code>.use()</code> call to add our auth middleware alongside our existing request logger.</p><pre><code class="language-ts">// start/routes.ts
import { controllers } from '#generated/controllers'
import { middleware } from '#start/kernel'
import router from '@adonisjs/core/services/router'

router.where('id', router.matchers.number())

router.on('/').render('pages/home').as('home')

router.on('/terms').render('pages/terms')

router
  .resource('challenges', controllers.Challenges)
  .use(['index', 'edit', 'create'], middleware.requestLogger())
++  .use(['create', 'store', 'edit', 'update', 'destroy'], middleware.auth())</code></pre><p>We've left <code>index</code> and <code>show</code> out of the auth group intentionally as anyone should be able to browse the challenges list and view individual challenges. Only creating, editing, and deleting need to be locked down.</p><p>Now, if an unauthenticated user tries to visit <code>/challenges/create</code>, the <code>auth</code> middleware will intercept and redirect them to <code>/login</code> instead.</p><h2>Attaching the Creator</h2><p>Back in our <a target="_blank" rel="nofollow noopener noreferrer" href="https://adocasts.com/lessons/crud-basics">CRUD Basics lesson</a>, we hit a foreign key constraint when creating challenges because our <code>challenges</code> table requires a <code>creator_id</code>. As a temporary fix, we hardcoded <code>1</code> to make things work while we got our data layer in shape. Now that we have authentication in place, let's use the real authenticated user's id.</p><pre><code class="language-ts">// app/controllers/challenges_controller.ts

  async store({ request, response, session, auth }: HttpContext) {
    const data = await request.validateUsing(challengeValidator)
++    await Challenge.create({ creatorId: auth.user!.id, ...data })
    session.flash('success', 'Challenge created successfully')
    return response.redirect().toRoute('challenges.index')
  }</code></pre><p>Notice the <code>!</code> after <code>auth.user</code>. TypeScript types <code>auth.user</code> as potentially <code>undefined</code> because it doesn't know which routes have the <code>auth</code> middleware applied. The <code>!</code> is a <a target="_blank" rel="noopener nofollow" class="external-link" href="https://www.typescriptlang.org/docs/handbook/2/everyday-types.html#non-null-assertion-operator-postfix-">non-null assertion</a>, it tells TypeScript "trust me, this will not be undefined here". We can safely make that assertion because our <code>auth</code> middleware guarantees the user is authenticated before this method ever runs.</p><h2>Verifying Ownership</h2><p>Requiring authentication gets us partway there, but any logged-in user can still edit or delete any challenge, regardless of who created it. We need to check that the authenticated user is actually the creator before allowing those mutations through.</p><p>The pattern is straightforward: find the challenge, compare its <code>creatorId</code> to the authenticated user's id, and redirect with an error flash if they don't match.</p><p>Starting with <code>edit</code>:</p><pre><code class="language-ts">// app/controllers/challenges_controller.ts

++  async edit({ params, view, auth, response, session }: HttpContext) {
    const challenge = await Challenge.findOrFail(params.id)

++    if (challenge.creatorId !== auth.user!.id) {
++      session.flash('error', 'You do not have permission to edit this challenge')
++      return response.redirect().toRoute('challenges.index')
++    }

    return view.render('pages/challenges/edit', { challenge })
  }</code></pre><p>For <code>update</code>, the ownership check comes before the validation. There's no point running our validator if the user doesn't have permission to make this change in the first place. You'll also notice we've moved the <code>Challenge.findOrFail</code> up to the top so we have the record in hand before doing anything else.</p><pre><code class="language-ts">// app/controllers/challenges_controller.ts

++  async update({ params, request, response, auth, session }: HttpContext) {
++    const challenge = await Challenge.findOrFail(params.id)

++    if (challenge.creatorId !== auth.user!.id) {
++      session.flash('error', 'You do not have permission to edit this challenge')
++      return response.redirect().toRoute('challenges.index')
++    }

    const data = await request.validateUsing(challengeValidator)
    challenge.merge(data)
    await challenge.save()
    return response.redirect().toRoute('challenges.show', { id: params.id })
  }</code></pre><p>And finally, <code>destroy</code>:</p><pre><code class="language-ts">// app/controllers/challenges_controller.ts

++  async destroy({ params, response, session, auth }: HttpContext) {
    const challenge = await Challenge.findOrFail(params.id)

++    if (challenge.creatorId !== auth.user!.id) {
++      session.flash('error', 'You do not have permission to delete this challenge')
++      return response.redirect().toRoute('challenges.index')
++    }

    await challenge.related('participants').detach()
    await challenge.delete()
    session.flash('success', `${challenge.text} has been deleted`)
    return response.redirect().toRoute('challenges.index')
  }</code></pre><h2>Hiding Actions in the Views</h2><p>Our backend is now protected, but the UI still shows the "Create a new challenge" button to guests and still shows the edit and delete controls to everyone viewing a challenge. Those buttons either lead to a redirect or an access error, so let's clean that up and only show them when they actually apply.</p><p>On the challenges index, we only want to show the create button if there's an authenticated user, so we can wrap it in a simple <code>@if</code> check.</p><pre><code class="language-edge">// resources/views/pages/challenges/index.edge

++@if (auth.user)
  &lt;a href="{{ route('challenges.create') }}"
    class="button"&gt;Create a new challenge&lt;/a&gt;
++@endif</code></pre><p>On the challenge detail page, the edit and delete actions should only be visible to the challenge's creator. We have <code>auth.user</code> available in the template thanks to <code>silent_auth_middleware</code>, so we can compare <code>challenge.creatorId</code> directly.</p><pre><code class="language-edge">// resources/views/pages/challenges/show.edge

++@if (challenge.creatorId === auth.user?.id)
  &lt;a href="{{ editUrl }}" class="button"&gt;Edit Challenge&lt;/a&gt;
  &lt;button type="submit" form="destroy" class="button destructive"&gt;Delete&lt;/button&gt;
++@endif</code></pre><p>Note that we're using <code>auth.user?.id</code> here rather than <code>auth.user!.id</code>. This page is public, so <code>auth.user</code> may be <code>undefined</code> for guests. The <a target="_blank" rel="noopener nofollow" class="external-link" href="https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Optional_chaining">optional chaining</a> means the comparison simply evaluates to <code>false</code> when no one is logged in, which is exactly what we want.</p><p>This is a perfectly workable approach for a simple application, but you can imagine it getting repetitive fast. If we add more resources, we'd be writing the same ownership check pattern over and over. That's exactly what AdonisJS's <a target="_blank" rel="noopener nofollow" class="external-link" href="https://docs.adonisjs.com/guides/auth/authorization">Bouncer</a> package solves: it gives us a centralized, reusable way to define and check authorization rules. We'll cover it in the next module.</p>]]></content:encoded>
      </item>
      <item>
        <title><![CDATA[Sign In & Sign Out Flows]]></title>
        <link>https://adocasts.com/lessons/sign-in-and-sign-out-flows</link>
        <guid>https://adocasts.com/lessons/sign-in-and-sign-out-flows</guid>
        <pubDate>Fri, 05 Jun 2026 12:00:00 +0000</pubDate>
        <description><![CDATA[Implement sign in and sign out in AdonisJS. Learn to verify credentials, manage sessions, and protect routes with auth middleware.]]></description>
        <content:encoded><![CDATA[<p>Next, let's tackle the other half of authentication: signing in and signing out. To do this, we'll create a new <code>SessionController</code>, because authentication creates and destroys a "session" for the user, so our <code>create</code> method will display the login form, <code>store</code> will authenticate the user and create their session, and <code>destroy</code> will log the user out. Again, following the <a target="_blank" rel="noopener nofollow" class="external-link" href="https://adocasts.com/lessons/crud-basics">resourceful naming convention</a>.</p><pre><code class="language-bash">node ace make:controller session create store destroy
# DONE:    create app/controllers/session_controller.ts</code></pre><p>That'll give us a stubbed controller with three empty methods.</p><pre><code class="language-ts">// app/controllers/session_controller.ts
import type { HttpContext } from '@adonisjs/core/http'

export default class SessionController {
  
  async create({}: HttpContext) {}
  
  async store({}: HttpContext) {}
  
  async destroy({}: HttpContext) {}
  
}</code></pre><h2>Displaying The Login Form</h2><p>Starting with the most straightforward, <code>create</code> just needs to render our login page.</p><pre><code class="language-ts">// app/controllers/session_controller.ts
import type { HttpContext } from '@adonisjs/core/http'

export default class SessionController {
  
++  async create({ view }: HttpContext) {
++    return view.render('pages/auth/login')
++  }
  
  async store({}: HttpContext) {}
  
  async destroy({}: HttpContext) {}
  
}</code></pre><p>Remember, in the first lesson of this module, we left the pages from the starter kit intact since they didn't include anything we hadn't already discussed. So, that is already good to go.</p><h2>Authenticating The User</h2><p>For our <code>store</code> method, we'll start by pulling <code>email</code> and <code>password</code> out of the request body.</p><pre><code class="language-ts">// app/controllers/session_controller.ts
import type { HttpContext } from '@adonisjs/core/http'

export default class SessionController {
  
  async create({ view }: HttpContext) {
    return view.render('pages/auth/login')
  }
  
++  async store({ request, auth, response }: HttpContext) {
++    const { email, password } = request.all()
++  }
  
  async destroy({}: HttpContext) {}
  
}</code></pre><p>Now, instead of manually looking up the user and comparing passwords ourselves, our <code>User</code> model provides a <code>verifyCredentials</code> static method that handles all of that for us. Under the hood, it finds the user by their email, then uses the hash service to verify that the provided password matches the stored hash.</p><pre><code class="language-ts">// app/controllers/session_controller.ts
import User from '#models/user'
import type { HttpContext } from '@adonisjs/core/http'

export default class SessionController {
  
  async create({ view }: HttpContext) {
    return view.render('pages/auth/login')
  }
  
  async store({ request, auth, response }: HttpContext) {
    const { email, password } = request.all()
++    const user = await User.verifyCredentials(email, password)
  }
  
  async destroy({}: HttpContext) {}
  
}</code></pre><p>Importantly, if either the email doesn't match a user in our database or the password is incorrect, <code>verifyCredentials</code> will throw an <code>InvalidCredentialsException</code>. This is deliberately vague. Rather than telling the user "no account with that email exists" or "that password is wrong", it simply says the credentials are invalid.</p><p>This is a security best practice. If we revealed which part of the credentials was wrong, we'd be confirming which emails have accounts, which an attacker could use to narrow their targets.</p><p>AdonisJS's exception handler picks up <code>InvalidCredentialsException</code> and, for HTML form submissions, redirects the user back with a flash message, similar to how validation errors are handled automatically.</p><p>If <code>verifyCredentials</code> succeeds, we get back the user's <code>User</code> model instance, and we can log them in exactly as we did after sign up.</p><pre><code class="language-ts">// app/controllers/session_controller.ts
import User from '#models/user'
import type { HttpContext } from '@adonisjs/core/http'

export default class SessionController {
  
  async create({ view }: HttpContext) {
    return view.render('pages/auth/login')
  }
  
  async store({ request, auth, response }: HttpContext) {
    const { email, password } = request.all()
    const user = await User.verifyCredentials(email, password)

++    await auth.use('web').login(user)
++    return response.redirect().toRoute('home')
  }
  
  async destroy({}: HttpContext) {}
  
}</code></pre><h2>Signing Out</h2><p>Signing out is refreshingly simple. We tell <code>auth</code> to log the user out, then redirect them somewhere. We'll send them back to the login page.</p><pre><code class="language-ts">// app/controllers/session_controller.ts
import User from '#models/user'
import type { HttpContext } from '@adonisjs/core/http'

export default class SessionController {
  
  async create({ view }: HttpContext) {
    return view.render('pages/auth/login')
  }
  
  async store({ request, auth, response }: HttpContext) {
    const { email, password } = request.all()
    const user = await User.verifyCredentials(email, password)

    await auth.use('web').login(user)
    return response.redirect().toRoute('home')
  }
  
++  async destroy({ auth, response }: HttpContext) {
++    await auth.use('web').logout()
++    return response.redirect().toRoute('session.create')
++  }
  
}</code></pre><h2>Registering Routes &amp; Middleware</h2><p>With our controller sorted, let's register our routes. We'll also reach for our <code>guest</code> and <code>auth</code> named middleware from <code>start/kernel.ts</code> here, which is where the starter kit has them preconfigured for us.</p><p>We want to group the signup and login routes under the <code>guest</code> middleware. The idea is that an already-authenticated user has no business visiting the login or signup pages, so if they try, the middleware redirects them home.</p><pre><code class="language-ts">// start/routes.ts
import { controllers } from '#generated/controllers'
import { middleware } from '#start/kernel'
import router from '@adonisjs/core/services/router'

router.where('id', router.matchers.number())

router.on('/').render('pages/home').as('home')

router.on('/terms').render('pages/terms')

router
  .resource('challenges', controllers.Challenges)
  .use(['index', 'edit', 'create'], middleware.requestLogger())

++router
++  .group(() =&gt; {
    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())</code></pre><p>The <code>guest</code> middleware checks whether the user making the request is already authenticated. If they are, it reflashes their existing session data and redirects them to the home page.</p><p>Then we add a separate group for the logout route protected by the <code>auth</code> middleware.</p><pre><code class="language-ts">// start/routes.ts
import { controllers } from '#generated/controllers'
import { middleware } from '#start/kernel'
import router from '@adonisjs/core/services/router'

router.where('id', router.matchers.number())

router.on('/').render('pages/home').as('home')

router.on('/terms').render('pages/terms')

router
  .resource('challenges', controllers.Challenges)
  .use(['index', 'edit', 'create'], middleware.requestLogger())

router
  .group(() =&gt; {
    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())

++router
++  .group(() =&gt; {
++    router.post('/logout', [controllers.Session, 'destroy'])
++  })
++  .use(middleware.auth())</code></pre><p>The <code>auth</code> middleware checks that the user is authenticated before letting the request through. If they're not, it redirects them to <code>/login</code> instead. Note that the <code>auth</code> middleware isn't limited to just the logout route; you can wrap any routes you want to protect behind it. For example, if you only want signed-in users to create challenges, which we do, you could add the auth middleware to the create, store, edit, update, and delete endpoints of our challenges resource.</p><h2>Completing The Header</h2><p>Last thing! Back in our header, we can now safely fill in that <code>{{-- TODO --}}</code> we left behind two lessons ago. Our session routes are registered, so we won't get any route not found errors.</p><pre><code class="language-edge">// resources/views/partials/header.edge
&lt;header&gt;
  &lt;div&gt;
    &lt;div&gt;
      @link({ route: 'home', text: 'Home' })
        {{-- svg ... --}}
      @end
    &lt;/div&gt;
    &lt;div&gt;
      &lt;nav&gt;
        @if(auth.user)
          @!avatar({ initials: auth.user.initials })
++          @form({ route: 'session.destroy', method: 'POST' })
++            @!button({ text: 'Logout' })
++          @end
        @else
          @!link({ route: 'new_account.create', text: 'Signup' })
++          @!link({ route: 'session.create', text: 'Login' })
        @end
      &lt;/nav&gt;
    &lt;/div&gt;
  &lt;/div&gt;
&lt;/header&gt;</code></pre><p>When the user is authenticated, we show their avatar alongside a logout button. The logout is wrapped in a <code>@form</code> that POSTs to <code>session.destroy</code> rather than using a plain link, and that's intentional. Links can be pre-fetched by browsers, meaning a user could be logged out simply because a page eagerly loaded the link in the background. Using a form and a POST request prevents that, and also protects against <a target="_blank" rel="noopener nofollow" class="external-link" href="https://docs.adonisjs.com/guides/security/securing-ssr-applications">CSRF-based forced logouts</a>.</p><p>When no user is authenticated, we show links to both signup and login.</p><p>With that, authentication is fully wired up! Head to <code>/login</code> in your browser, enter the credentials of the account you signed up with, and you should land back on the home page with your initials showing in the header. Hit logout, and you should be sent right back to <code>/login</code>.</p><p>Next, we'll require and attach an authenticated user when creating challenges.</p>]]></content:encoded>
      </item>
      <item>
        <title><![CDATA[Sign Up & Securing User Passwords]]></title>
        <link>https://adocasts.com/lessons/sign-up-and-securing-user-passwords</link>
        <guid>https://adocasts.com/lessons/sign-up-and-securing-user-passwords</guid>
        <pubDate>Fri, 05 Jun 2026 12:00:00 +0000</pubDate>
        <description><![CDATA[Build a user signup in AdonisJS. Implement secure password hashing, user creation, and form validation for registration.]]></description>
        <content:encoded><![CDATA[<p>Let's start with our sign up flow, as this is how users will be created inside our application. For this, we'll create a <code>NewAccountController</code> with a <code>create</code> and <code>store</code> method inside of it. Again, following the <a target="_blank" rel="noopener nofollow" class="external-link" href="https://adocasts.com/lessons/crud-basics">resourceful naming convention</a>.</p><pre><code class="language-bash">node ace make:controller new_account create store
# DONE:    create app/controllers/new_account_controller.ts</code></pre><p>Remember, with the <code>make:controller</code> command we can end with a list of methods we want stubbed inside the controller. So, with this we're creating a controller called <code>new_account</code> with two methods, <code>create</code> and <code>store</code>. It'll look like the following to start:</p><pre><code class="language-ts">// app/controllers/new_account_controller.ts
import type { HttpContext } from '@adonisjs/core/http'

export default class NewAccountController {
  
  async create({}: HttpContext) {}
  
  async store({}: HttpContext) {}
  
}</code></pre><p>We've left our EdgeJS pages intact as we're already familiar with everything going on in those. So, we can get our <code>create</code> out of the way by rendering its page.</p><pre><code class="language-ts">// app/controllers/new_account_controller.ts
import type { HttpContext } from '@adonisjs/core/http'

export default class NewAccountController {
  
++  async create({ view }: HttpContext) {
++    return view.render('pages/auth/signup')
++  }
  
  async store({}: HttpContext) {}
  
}</code></pre><p>As a reminder, this page includes a form that'll post to <code>new_account.store</code> with the fields <code>fullName</code>, <code>email</code>, <code>password</code>, and <code>passwordConfirmation</code>, as shown below.</p><pre><code class="language-edge">// resources/views/pages/auth/signup.edge
@layout()
  &lt;div class="form-container"&gt;
    &lt;div&gt;
      &lt;h1&gt; Signup &lt;/h1&gt;
      &lt;p&gt;
        Enter your details below to create your account
      &lt;/p&gt;
    &lt;/div&gt;

    &lt;div&gt;
      @form({ route: 'new_account.store', method: 'POST' })
        &lt;div&gt;
          @field.root({ name: 'fullName' })
            @!field.label({ text: 'Full name' })
            @!input.control()
            @!field.error()
          @end
        &lt;/div&gt;

        &lt;div&gt;
          @field.root({ name: 'email' })
            @!field.label({ text: 'Email' })
            @!input.control({ type: 'email', autocomplete: 'email' })
            @!field.error()
          @end
        &lt;/div&gt;

        &lt;div&gt;
          @field.root({ name: 'password' })
            @!field.label({ text: 'Password' })
            @!input.control({ type: 'password', autocomplete: 'new-password' })
            @!field.error()
          @end
        &lt;/div&gt;

        &lt;div&gt;
          @field.root({ name: 'passwordConfirmation' })
            @!field.label({ text: 'Confirm password' })
            @!input.control({ type: 'password', autocomplete: 'new-password' })
            @!field.error()
          @end
        &lt;/div&gt;

        &lt;div&gt;
          @!button({ text: 'Sign up', type: 'submit' })
        &lt;/div&gt;
      @end
    &lt;/div&gt;
  &lt;/div&gt;
@end</code></pre><p>We'll next want to validate this data so we aren't accepting raw user-provided data. So, let's create our validator next.</p><pre><code class="language-bash">node ace make:validator user
# DONE:    create app/validators/user.ts</code></pre><p>To start, that'll give us a new <code>user</code> validator file with a single import for <a target="_blank" rel="noopener nofollow" class="external-link" href="https://vinejs.dev/docs/introduction">VineJS</a>.</p><pre><code class="language-ts">// app/validators/user.ts
import vine from '@vinejs/vine'</code></pre><p>We'll want to create a new validator in here called <code>signupValidator</code> and export it. Inside, we'll add our form's fields, all of which will be strings.</p><pre><code class="language-ts">// app/validators/user.ts
import vine from '@vinejs/vine'

++export const signupValidator = vine.create({
++  fullName: vine.string(),
++  email: vine.string(),
++  password: vine.string(),
++})</code></pre><p>Do note, we've omitted the <code>passwordConfirmation</code> field from our validator. We don't actually need that value; all we want to do is ensure it matches one-to-one with the <code>password</code> value. To do that, we can add a <code>confirmed()</code> rule to our password stating it should be confirmed by our <code>passwordConfirmation</code> value.</p><pre><code class="language-ts">// app/validators/user.ts
import vine from '@vinejs/vine'

export const signupValidator = vine.create({
  fullName: vine.string(),
  email: vine.string(),
++  password: vine.string().confirmed({
++    confirmationField: 'passwordConfirmation',
++  }),
})</code></pre><p>Next, we'll also want to ensure it is at least eight characters long. You can change to match your security policy from there if you'd like. To do this, we could chain it directly to our password rules, but let's mimic what the starter kit had. It had these rules split into their own variable, which opens up the opportunity for reusability for these rules. You might want to reuse this if you have a change password section in your account settings, for example.</p><pre><code class="language-ts">// app/validators/user.ts
import vine from '@vinejs/vine'

++const password = () =&gt; vine.string().minLength(8).maxLength(32)

export const signupValidator = vine.create({
  fullName: vine.string(),
  email: vine.string(),
++  password: password().confirmed({
    confirmationField: 'passwordConfirmation',
  }),
})</code></pre><p>Here we're adding a max length for the password to 32 to match what the starter kit provided, but you can allow this to go longer if you'd like. By default, our database string column length will be 255 characters. Plus, as we discussed in our database module, our <code>User</code> model will secure our password inside our database using a <a target="_blank" rel="noopener nofollow" class="external-link" href="https://docs.adonisjs.com/guides/security/hashing">hash and salt mechanism</a>. This results in our password looking like the below inside our database.</p><pre><code>$scrypt$n=16384,r=8,p=1$MKExIjg7F/RbrIRl7ZlKPg$Sajr82p4xhTr+0Kw/z6cakM00gh4mDDsil5Ts954pYvuAl7eS2CNLRQzuharYaWnavyx4JrF8EI2zXOxfsnltw</code></pre><p>The length of the passwords stored in our database ultimately varies depending on our hash configuration. By default, AdonisJS has set us up with <code>scrypt. We can switch to [argon2](https://docs.adonisjs.com/guides/security/hashing), [bcrypt](https://docs.adonisjs.com/guides/security/hashing), or other solutions via this config if needed. The </code>keyLength` option, omitted from below, is what would add or subtract from the length of the secured password.</p><pre><code class="language-ts">// config/hash.ts
import { defineConfig, drivers } from '@adonisjs/core/hash'

/**
 * Hashing configuration.
 * Defines the hashing algorithm and parameters used for password hashing.
 */
const hashConfig = defineConfig({
  /**
   * The default hashing algorithm to use.
   */
  default: 'scrypt',

  /**
   * List of available hashers and their configuration.
   */
  list: {
    /**
     * Scrypt hasher configuration.
     * Scrypt is a password-based key derivation function designed to be
     * computationally intensive and memory-hard to resist brute-force attacks.
     */
    scrypt: drivers.scrypt({
      /**
       * CPU/memory cost parameter.
       * Higher values increase security but require more resources.
       */
      cost: 16384,

      /**
       * Block size parameter.
       * Affects memory usage and performance.
       */
      blockSize: 8,

      /**
       * Parallelization parameter.
       * Controls the number of independent mixing functions.
       */
      parallelization: 1,

      /**
       * Maximum memory in bytes to use for hashing.
       * Set to 32MB (33554432 bytes) by default.
       */
      maxMemory: 33554432,
    }),
  },
})

export default hashConfig

/**
 * Inferring types for the list of hashers you have configured
 * in your application.
 */
declare module '@adonisjs/core/types' {
  export interface HashersList extends InferHashers&lt;typeof hashConfig&gt; {}
}</code></pre><p>Several AdonisJS configurations follow this structure. We have a <code>list</code> of configured services, in this case, hashing mechanisms. Then, we specify a default for AdonisJS to use from the list.</p><p>Okay, back to our validator. On our <code>users</code> table our <code>email</code> column is marked as <code>unique</code>. We don't want to allow more than one user to use an email; otherwise, that could lead to our accounts cross-pollinating. We can validate this up front to prevent it from failing and erroring out later on when it reaches our database using the <code>unique()</code><a target="_blank" rel="noopener nofollow" class="external-link" href="https://docs.adonisjs.com/guides/basics/validation"> rule</a>.</p><pre><code class="language-ts">// app/validators/user.ts
import vine from '@vinejs/vine'

++const password = () =&gt; vine.string().minLength(8).maxLength(32)

export const signupValidator = vine.create({
  fullName: vine.string(),
++  email: vine.string().unique({ table: 'users', column: 'email' }),
  password: password().confirmed({
    confirmationField: 'passwordConfirmation',
  }),
})</code></pre><p>It accepts in options that will help it build out a query. Alternatively, we can also provide a callback function to build the query manually ourselves. Here, we're specifying that we want the user-supplied <code>email</code> value to be unique in our <code>users</code> table's <code>email</code> column. This will perform a quick query to ensure no record with the supplied email exists. If one does exist, we'll get a validation error.</p><p>Note, there is also a property on this option set to do a case-insensitive search and another to filter further.</p><pre><code class="language-ts">import vine from '@vinejs/vine'

export const exampleValidator = vine.create({
  email: vine.string().unique({ 
    table: 'users', 
    column: 'email',
    caseInsensitive: true,
    filter(db, value) {
      return db.whereNull('deleted_at')
    }
  }),
})</code></pre><p>Then, we also need to ensure the value supplied is a valid email structure and that it'll fit in our database column.</p><pre><code class="language-ts">// app/validators/user.ts
import vine from '@vinejs/vine'

++const email = () =&gt; vine.string().email().maxLength(254)
const password = () =&gt; vine.string().minLength(8).maxLength(32)

export const signupValidator = vine.create({
  fullName: vine.string(),
++  email: email().unique({ table: 'users', column: 'email' }),
  password: password().confirmed({
    confirmationField: 'passwordConfirmation',
  }),
})</code></pre><p>We'll do something similar to our password and make this reusable, which is handy if you have a change email section in your account settings. Finally, our <code>fullName</code> should be nullable; the user doesn't need to supply it.</p><pre><code class="language-ts">// app/validators/user.ts
import vine from '@vinejs/vine'

const email = () =&gt; vine.string().email().maxLength(254)
const password = () =&gt; vine.string().minLength(8).maxLength(32)

export const signupValidator = vine.create({
++  fullName: vine.string().nullable(),
  email: email().unique({ table: 'users', column: 'email' }),
  password: password().confirmed({
    confirmationField: 'passwordConfirmation',
  }),
})</code></pre><p>Jumping back into our <code>NewAccountController</code>, we can validate our request body in our <code>store</code> handler, then use the validated data to create a new user record. We can also redirect our user back home after all is said and done.</p><pre><code class="language-ts">// app/controllers/new_account_controller.ts
import type { HttpContext } from '@adonisjs/core/http'

export default class NewAccountController {
  
  async create({ view }: HttpContext) {
    return view.render('pages/auth/signup')
  }
  
++  async store({ request }: HttpContext) {
++    const payload = await request.validateUsing(signupValidator)
++    const user = await User.create(payload)

++    return response.redirect().toRoute('home')
++  }
  
}</code></pre><p>In between creating our user and redirecting our user, though, we want to log the user in. We've discussed how <code>auth</code> is a global in our EdgeJS context, but it's also available via our <code>HttpContext</code> as well! It's how we'll login, logout, and otherwise manage our session-based authentication.</p><pre><code class="language-ts">// app/controllers/new_account_controller.ts
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) {
    const payload = await request.validateUsing(signupValidator)
    const user = await User.create(payload)

++    await auth.use('web').login(user)

    return response.redirect().toRoute('home')
  }
  
}</code></pre><p>Similar to how hash has a list of configured hashing mechanisms, our auth has a list of configured authentication guards. With the starter kit we've selected, the default guard that's been configured for us is the "web" guard, for <a target="_blank" rel="noopener nofollow" class="external-link" href="https://docs.adonisjs.com/guides/auth/session-guard">session authentication</a>. Here we're using that guard to login the provided user.</p><p>If we scope out our auth config to see this guard, we'll find:</p><pre><code class="language-ts">// config/auth.ts
import { defineConfig } from '@adonisjs/auth'
import { sessionGuard, sessionUserProvider } from '@adonisjs/auth/session'
import type { InferAuthenticators, InferAuthEvents, Authenticators } from '@adonisjs/auth/types'

/**
 * Authentication configuration.
 * Defines guards and user providers for session-based authentication.
 */
const authConfig = defineConfig({
  /**
   * The default guard to use for authentication.
   * This guard will be used when no specific guard is mentioned.
   */
  default: 'web',

  /**
   * List of authentication guards.
   * Guards define how users are authenticated for different parts of the application.
   */
  guards: {
    /**
     * Web guard uses session-based authentication for web requests.
     */
    web: sessionGuard({
      /**
       * Whether to use "remember me" tokens for persistent authentication.
       * When enabled, users can stay logged in across browser sessions.
       */
      useRememberMeTokens: false,

      /**
       * User provider configuration.
       * Defines how to fetch and verify user credentials.
       */
      provider: sessionUserProvider({
        model: () =&gt; import('#models/user'),
      }),
    }),
  },
})

export default authConfig

/**
 * Inferring types from the configured auth
 * guards.
 */
declare module '@adonisjs/auth/types' {
  export interface Authenticators extends InferAuthenticators&lt;typeof authConfig&gt; {}
}
declare module '@adonisjs/core/types' {
  interface EventsList extends InferAuthEvents&lt;Authenticators&gt; {}
}</code></pre><p>Note, the web guard is our default guard, and within its configuration is a provider. This provider accepts in our <code>User</code> model, and that is how the <code>login()</code> method we used in our controller knows how to accept a <code>User</code> model instance, among other things.</p><p>Great, the last thing to do is register our routes!</p><pre><code class="language-ts">// start/routes.ts
import { controllers } from '#generated/controllers'
import { middleware } from '#start/kernel'
import router from '@adonisjs/core/services/router'

router.where('id', router.matchers.number())

router.on('/').render('pages/home').as('home')

router.on('/terms').render('pages/terms')

router
  .resource('challenges', controllers.Challenges)
  .use(['index', 'edit', 'create'], middleware.requestLogger())

++router.get('/signup', [controllers.NewAccount, 'create'])
++router.post('/signup', [controllers.NewAccount, 'store'])</code></pre><p>Remember, <a target="_blank" rel="noopener nofollow" class="external-link" href="https://adocasts.com/lessons/controllers-barrel-files-and-subpath-imports">barrel files</a> are updated when the dev server is running. You may need to boot your dev server to get the <code>NewAccountController</code> added to the <code>controllers.NewAccount</code> barrel mapping.</p><p>Finally, back into our header, we can add our sign up link and our authenticated user's avatar.</p><pre><code class="language-edge">// resource/views/partials/header.edge
&lt;header&gt;
  &lt;div&gt;
    &lt;div&gt;
      @link({ route: 'home', text: 'Home' })
        {{-- svg ... --}}
      @end
    &lt;/div&gt;
    &lt;div&gt;
      &lt;nav&gt;
        @if(auth.user)
          @!avatar({ initials: auth.user.initials })
        @else
          @!link({ route: 'new_account.create', text: 'Signup' })
        @end
      &lt;/nav&gt;
    &lt;/div&gt;
  &lt;/div&gt;
&lt;/header&gt;</code></pre><p>Here, when a user is authenticated, our <code>auth.user</code> will be populated with the user's <code>User</code> model instance. So, we can use it to show an avatar of their initials. Otherwise, we'll show a link to our sign up page.</p><p>That should do it, we should now be able to head into our <code>/signup</code> page in our app and sign up users! Once you sign up, you should be redirected back to the home page, and you should see your user's initials in place of the sign up link.</p>]]></content:encoded>
      </item>
      <item>
        <title><![CDATA[What's New In AdonisJS - Week of May 24, 2026]]></title>
        <link>https://adocasts.com/news/whats-new-in-adonisjs-may-23-30-2026</link>
        <guid>https://adocasts.com/news/whats-new-in-adonisjs-may-23-30-2026</guid>
        <pubDate>Sat, 30 May 2026 22:15:00 +0000</pubDate>
        <description><![CDATA[A roundup of what the AdonisJS team shipped the week of May 23–30, 2026, including a macroable Router, first-class ESLint presets for React and Vue, and a handful of bug fixes and cleanup.]]></description>
        <content:encoded><![CDATA[<p>Hey there! Hope everyone is having a great week. I set up a weekly job to have AI summarize changes across the AdonisJS repositories with what's new, fixed, etc. This info was originally just for my own gain to keep up-to-date with things easier. However, I figured this might also be beneficial to others as well, so why not put it out as a weekly blog. We'll trial it and see how it goes, let me know if you like this idea or have suggestions in the comments below!</p><hr><p><em>The following is AI generated based on GitHub activity across the AdonisJS repositories.</em></p><hr><h2><a target="_blank" rel="nofollow noopener noreferrer" class="underline underline underline-offset-2 decoration-1 decoration-current/40 hover:decoration-current focus:decoration-current" href="https://github.com/adonisjs/http-server/pull/119">Router is Now Macroable</a></h2><p>This one is probably the most interesting change of the week. The <code>Router</code> class in <code>adonisjs/http-server</code> now extends <code>Macroable</code>, which means you can add custom methods and getters to it from your own code or from a package — without touching the framework source.</p><p>If you've used AdonisJS for a while, you may have run into situations where you wanted to attach a convenience method to the router. Before this change, there was no clean, official way to do that. Now there is.</p><p>Here's a quick example of what that looks like in practice:</p><pre><code class="language-typescript">import router from '@adonisjs/core/services/router'
import { Router } from '@adonisjs/http-server'

Router.macro('apiPrefix', function (this: Router, prefix: string) {
  return this.group(() =&gt; {}).prefix(prefix)
})

// Then, anywhere in your routes file:
router.apiPrefix('/api/v1').resource('users', UsersController)</code></pre><p>So you're essentially teaching the <code>Router</code> class a new method. Once registered, that method is available on the <code>router</code> singleton just like any built-in method. It's a super clean extension point, and I expect package authors to start taking advantage of this pretty quickly.</p><p>This shipped as <code>adonisjs/http-server v9.0.2</code>.</p><hr><h2><a target="_blank" rel="nofollow noopener noreferrer" class="underline underline underline-offset-2 decoration-1 decoration-current/40 hover:decoration-current focus:decoration-current" href="https://github.com/adonisjs/eslint-config/pull/15">React and Vue ESLint Presets</a></h2><p>The <code>@adonisjs/eslint-config</code> package picked up dedicated <code>react</code> and <code>vue</code> presets this week. If you're building an AdonisJS app with Inertia on the frontend, this is going to clean things up nicely.</p><p>Previously, if you wanted proper ESLint coverage across your Vue or React files, you had to wire that up yourself alongside the AdonisJS config. Now it's a single option:</p><pre><code class="language-typescript">// eslint.config.js
import { configApp } from '@adonisjs/eslint-config'
import { vue } from '@adonisjs/eslint-config/vue'

export default configApp(...vue, {
  name: 'inertia-vue ts overrides',
  files: ['inertia/**/*.ts'],
  rules: {
    'vue/component-api-style': 'off',
  },
})</code></pre><p>The <code>frontend</code> key tells the config to pull in the appropriate rules for whichever framework you're using. The Inertia block also now automatically covers <code>.vue</code> and <code>.jsx</code> files, so you're not leaving those out of your linting setup by accident.</p><p>By the way, the official AdonisJS starter kits have already been updated to use these new presets, so if you scaffold a new project this week you'll get this out of the box.</p><hr><h2>Bug Fixes</h2><h3><a target="_blank" rel="nofollow noopener noreferrer" class="underline underline underline-offset-2 decoration-1 decoration-current/40 hover:decoration-current focus:decoration-current" href="https://github.com/adonisjs/http-server/releases/tag/v9.0.1">Request Authority Using the Wrong Host Method</a></h3><p>In <code>adonisjs/http-server</code>, the request's <code>authority</code> getter wasn't calling the internal <code>host</code> method — it was resolving the host through a different path. Depending on your setup, this could return inconsistent values. The fix is straightforward: <code>authority</code> now delegates to <code>host</code> internally, so you get consistent results either way. This landed in <code>v9.0.1</code>.</p><h3><a target="_blank" rel="nofollow noopener noreferrer" class="underline underline underline-offset-2 decoration-1 decoration-current/40 hover:decoration-current focus:decoration-current" href="https://github.com/adonisjs/application/releases/tag/v9.0.1">PM2 Environment Detection Was Checking the Wrong Variable</a></h3><p>In <code>adonisjs/application</code>, the code that detects whether your app is running under PM2 was checking the wrong environment variable. If you're running AdonisJS with PM2, some PM2-specific behavior may not have been triggering correctly as a result. This is fixed in <code>v9.0.1</code> of the <code>application</code> package.</p><hr><h2>Starter Kit Cleanup</h2><p>A few improvements landed in the official starter kits this week that are worth a quick mention.</p><p><a target="_blank" rel="nofollow noopener noreferrer" class="underline underline underline-offset-2 decoration-1 decoration-current/40 hover:decoration-current focus:decoration-current" href="https://github.com/adonisjs/starter-kits/pull/16"><strong>pnpm v11 install prompts.</strong></a> If you're using pnpm v11 to scaffold a new AdonisJS project, you were getting prompted to manually approve build scripts during install. The starter kits now include <code>pnpm.onlyBuiltDependencies</code> configuration in the generated templates, so that prompt goes away.</p><p><a target="_blank" rel="nofollow noopener noreferrer" class="underline underline underline-offset-2 decoration-1 decoration-current/40 hover:decoration-current focus:decoration-current" href="https://github.com/adonisjs/starter-kits/pull/15"><strong>Leftover </strong></a><code>appKey</code><a target="_blank" rel="nofollow noopener noreferrer" class="underline underline underline-offset-2 decoration-1 decoration-current/40 hover:decoration-current focus:decoration-current" href="https://github.com/adonisjs/starter-kits/pull/15"><strong> removed from </strong></a><code>config/app.ts</code><a target="_blank" rel="nofollow noopener noreferrer" class="underline underline underline-offset-2 decoration-1 decoration-current/40 hover:decoration-current focus:decoration-current" href="https://github.com/adonisjs/starter-kits/pull/15"><strong>.</strong></a> A stale <code>appKey</code> field was hanging around in the app config template — a leftover that's no longer used in AdonisJS v7. It's been cleaned up. Small thing, but one less "what is this doing here?" moment when you read through a freshly scaffolded project.</p><hr><h2>Documentation Updates</h2><p>A couple of doc improvements also landed this week.</p><p>The <a target="_blank" rel="nofollow noopener noreferrer" class="underline underline underline-offset-2 decoration-1 decoration-current/40 hover:decoration-current focus:decoration-current" href="https://github.com/adonisjs/v7-docs/pull/45">Inertia shared props documentation</a> now includes Vue.js examples alongside the existing React ones. If you're a Vue user following the Inertia docs, you no longer have to mentally translate from React — the Vue version is right there.</p><p>There was also a <a target="_blank" rel="nofollow noopener noreferrer" class="underline underline underline-offset-2 decoration-1 decoration-current/40 hover:decoration-current focus:decoration-current" href="https://github.com/adonisjs/v7-docs/pull/52">fix to the </a><code>deepMerge</code><a target="_blank" rel="nofollow noopener noreferrer" class="underline underline underline-offset-2 decoration-1 decoration-current/40 hover:decoration-current focus:decoration-current" href="https://github.com/adonisjs/v7-docs/pull/52"> documentation</a> in the Inertia section, where the usage examples were incorrect, and some <a target="_blank" rel="nofollow noopener noreferrer" class="underline underline underline-offset-2 decoration-1 decoration-current/40 hover:decoration-current focus:decoration-current" href="https://github.com/adonisjs/lucid.adonisjs.com/pull/68">expanded parameter documentation for several Lucid ORM methods</a> over on the Lucid docs site.</p><hr><p>So, all-in-all, a maintenance-heavy week with two features that'll genuinely improve day-to-day ergonomics — especially if you're building Inertia apps or writing packages that need to extend the router. Good steady progress from the team.</p>]]></content:encoded>
      </item>
      <item>
        <title><![CDATA[Pagination]]></title>
        <link>https://adocasts.com/lessons/pagination</link>
        <guid>https://adocasts.com/lessons/pagination</guid>
        <pubDate>Fri, 22 May 2026 12:00:00 +0000</pubDate>
        <description><![CDATA[Learn pagination in AdonisJS. Split large datasets into pages to improve performance and user experience with Lucid paginate().]]></description>
        <content:encoded><![CDATA[<p>If you've ever been on a site that's had its data spread across multiple numbered pages, like page 1, 2, 3, etc., that's called pagination. Pagination is a way to split large lists of data into multiple paged chunks. By paging our data, we make it easier for the user to sift through and load more quickly, so it's a win-win on both counts.</p><p>Pagination can be a pain to do, but AdonisJS makes it a breeze. We can easily paginate our data using the <a target="_blank" rel="nofollow noopener noreferrer" href="https://lucid.adonisjs.com/docs/pagination">query builder's </a><code>paginate()</code><a target="_blank" rel="nofollow noopener noreferrer" href="https://lucid.adonisjs.com/docs/pagination"> method</a>. It accepts two arguments: the current page of data we're on and the number of results we want per page. The page number starts at one and can increment from there.</p><pre><code class="language-ts">// app/controllers/challenges_controller.ts
export default class ChallengesController {
  /**
   * Display a list of resource
   */
  async index({ view }: HttpContext) {
    const challenges = await Challenge.query()
      .whereHas('createdBy', (creator) =&gt;
        creator.whereHas('profile', (profile) =&gt; profile.where('isPublic', true))
      )
      .preload('createdBy')
++      .paginate(1, 12)

    return view.render('pages/challenges/index', { challenges })
  }

  // ...
}</code></pre><p>For example, above, we're grabbing the first page of our challenges, where each page consists of 12 challenges per page. Though the amount we want per page may be static, in most cases, these are fluid numbers often stored in the URL's query string. This allows the user to specify which page of data they're after. It also allows them to specify the number of items they'd like to see per page.</p><p>We could grab this off our query string using <code>request.input()</code>, but as we learned several lessons ago, we want to be defensive when accepting data. We also learned that the query string gets merged with our body when passed to the request's validate method. So, we can easily validate our query string data using a validator.</p><p>We'll jump into our challenge validation file and add a <code>challengeIndexValidator</code>. If you're going to have a lot of similar paginated pages in your application, you could also just do a <code>paginationValidator</code> if you'd prefer.</p><pre><code class="language-ts">// app/validators/challenge.ts
import vine from '@vinejs/vine'

++export const challengeIndexValidator = vine.create({
++  page: vine
++    .number()
++    .parse((value) =&gt; value || 1)
++    .positive(),
++  perPage: vine
++    .number()
++    .parse((value) =&gt; value || 12)
++    .range([12, 100]),
++})

export const challengeValidator = vine.create({
  text: vine.string().minLength(5).maxLength(255),
  points: vine.number().range([1, 100]),
})</code></pre><p>With this, we're stating both <code>page</code> and <code>perPage</code> should be numbers. Our <code>page</code> should be positive (&gt; 0). Then, <code>perPage</code> should be between 12 and 100 to disallow users from requesting something like 10,000 in a page and bog things down. Something new we haven't yet discussed is <a target="_blank" rel="nofollow noopener noreferrer" href="https://vinejs.dev/docs/schema_101#parse-value-before-validation">the </a><code>parse()</code><a target="_blank" rel="nofollow noopener noreferrer" href="https://vinejs.dev/docs/schema_101#parse-value-before-validation"> method</a>. This is run before any of our validations and allows us to parse and return the input value ourselves. Here, we're using it to set a default value if a <code>page</code> or <code>perPage</code> are omitted. You can find the full list of rules available on <code>vine.number()</code> in the <a target="_blank" rel="nofollow noopener noreferrer" href="https://vinejs.dev/docs/types/number">VineJS number type docs</a>.</p><p>Then, we can use this validator in our controller and use the validated <code>page</code> and <code>perPage</code> for our pagination.</p><pre><code class="language-ts">// app/controllers/challenges_controller.ts
export default class ChallengesController {
  /**
   * Display a list of resource
   */
++  async index({ request, view }: HttpContext) {
++    const { page, perPage } = await request.validateUsing(challengeIndexValidator)
    const challenges = await Challenge.query()
      .whereHas('createdBy', (creator) =&gt;
        creator.whereHas('profile', (profile) =&gt; profile.where('isPublic', true))
      )
      .preload('createdBy')
++      .paginate(page, perPage)

    return view.render('pages/challenges/index', { challenges })
  }

  // ...
}</code></pre><p>Now, if we check out our page we'll notice we only see 12 challenges. However, we can update our URL to include a <code>page</code> and/or <code>perPage</code> query string to alter the page we're on and how many items we see per page.</p><pre><code>/challenges?page=2&amp;perPage=24</code></pre><p>When we call the <code>paginate()</code> method, notice our return type is no longer <code>Challenge[]</code>, an array of our <code>Challenge</code> model instances. Rather, it's of type <code>ModelPaginatorContract&lt;Challenge&gt;</code>. The <code>ModelPaginator</code> is a class that extends <code>SimplePaginator</code> that extends <code>Array</code>, meaning it is still iterable, hence why our loop to display the challenges isn't broken. However, it also contains properties and methods to inform and help us work with our paginator. You can find the full paginator API in the <a target="_blank" rel="nofollow noopener noreferrer" href="https://lucid.adonisjs.com/docs/pagination">Lucid pagination docs</a>.</p><pre><code class="language-ts">SimplePaginator {
  // data
  rows: T[]

  // metadata
  perPage: 12,
  currentPage: 1,
  firstPage: 1,
  isEmpty: false,
  total: 37,
  hasTotal: true,
  lastPage: 4,
  hasMorePages: true,
  hasPages: true,

  // methods
  all(), // returns all rows
  getMeta(), // returns JSON metadata
  toJSON(), // return { meta: getMeta(), data: all() }
  queryString(values: { [key: string]: any }), // add qs to generated URLs
  baseUrl(url: string), // set base URL for pagination links
  getUrl(page: number), // returns URL for the page
  getNextPageUrl(), // returns URL for the next page
  getPreviousPageUrl(), // returns URL for the previous page
  getUrlsForRange(start: number, end: number), // returns array of URLs for range
}</code></pre><p>So, despite our <code>challenges</code> consisting of only 12 items for our first page of data, we can still get at the overall total with ease via <code>challenges.total</code>.</p><pre><code class="language-edge">// resources/views/pages/challenges/index.edge
@layout()

  &lt;div&gt;
    @if (session.has('success'))
      &lt;div class="alert alert-success"&gt;
        {{ session.get('success') }}
      &lt;/div&gt;
    @endif

    &lt;div class="hero"&gt;
      &lt;h1&gt;Challenges&lt;/h1&gt;
  
      @include('partials/challenge/available_points')

++      &lt;div&gt;
++        Total Challenges: {{ challenges.total }}
++      &lt;/div&gt;
  
      &lt;a href="{{ route('challenges.create') }}" class="button"&gt;Create a new challenge&lt;/a&gt;
    &lt;/div&gt;

    @challenge.grid() 
      @each(challenge in challenges)
        @!challenge.gridItem({ challenge }) 
      @endeach
    @end
  &lt;/div&gt;

@end</code></pre><p>Now, we need to allow our users to easily traverse from page to page. Let's start by adding our next and previous links using the <code>getPreviousPageUrl()</code> and <code>getNextPageUrl()</code>. Importantly, note that if there isn't a previous or next page, these will return <code>null</code>, so we'll want to wrap them in an if statement.</p><pre><code class="language-edge">// resources/views/pages/challenges/index.edge
@layout()

  &lt;div&gt;
    {{-- ... --}}

    @challenge.grid() 
      @each(challenge in challenges)
        @!challenge.gridItem({ challenge }) 
      @endeach
    @end
    
++    &lt;div style="display: flex; gap: .25rem;"&gt;
++      @if (challenges.currentPage &gt; 1)
++        &lt;a href="{{ challenges.getPreviousPageUrl() }}"&gt;Prev&lt;/a&gt;
++      @endif
++
++      @if (challenges.hasMorePages)
++        &lt;a href="{{ challenges.getNextPageUrl() }}"&gt;Next&lt;/a&gt;
++      @endif
++    &lt;/div&gt;
  &lt;/div&gt;

@end</code></pre><p>So, if we're on page one, we won't see our previous link, and if we're on the last page, we won't see our next link. Additionally, if we check out the links generated here, they're pointing to <code>/?page=2</code>, which is our home page. We want this to instead be <code>/challenges?page=2</code> to keep the user on this page. For that, we can use <a target="_blank" rel="nofollow noopener noreferrer" href="https://lucid.adonisjs.com/docs/pagination#setting-base-url">the </a><code>baseUrl()</code><a target="_blank" rel="nofollow noopener noreferrer" href="https://lucid.adonisjs.com/docs/pagination#setting-base-url"> method</a> in our controller.</p><pre><code class="language-ts">// app/controllers/challenges_controller.ts
export default class ChallengesController {
  /**
   * Display a list of resource
   */
  async index({ request, view }: HttpContext) {
    const { page, perPage } = await request.validateUsing(challengeIndexValidator)
    const challenges = await Challenge.query()
      .whereHas('createdBy', (creator) =&gt;
        creator.whereHas('profile', (profile) =&gt; profile.where('isPublic', true))
      )
      .preload('createdBy')
      .paginate(page, perPage)

++    challenges.baseUrl(urlFor('challenges.index'))

    return view.render('pages/challenges/index', { challenges })
  }

  // ...
}</code></pre><p>Now, all the URLs generated by our paginator will point to <code>/challenges</code> instead of just <code>/</code>. However, if we switch our <code>perPage</code> to 24, for example, notice neither our next nor previous links keep that. We can easily fix that using the <code>queryString()</code> method!</p><pre><code class="language-ts">// app/controllers/challenges_controller.ts
export default class ChallengesController {
  /**
   * Display a list of resource
   */
  async index({ request, view }: HttpContext) {
    const { page, perPage } = await request.validateUsing(challengeIndexValidator)
    const challenges = await Challenge.query()
      .whereHas('createdBy', (creator) =&gt;
        creator.whereHas('profile', (profile) =&gt; profile.where('isPublic', true))
      )
      .preload('createdBy')
      .paginate(page, perPage)

    challenges.baseUrl(urlFor('challenges.index'))
++    challenges.queryString({ page, perPage })

    return view.render('pages/challenges/index', { challenges })
  }

  // ...
}</code></pre><p>Despite us adding <code>page</code> to the <code>queryString()</code> method, the URL generation will override it to allow linking to the correct page. If we had additional things we were filtering by, we'd use this same approach to keep those filters in our pagination links.</p><p>Now, one more thing while we're in our controller. When paginating, it's always recommended to sort your query. Without a sort, you run the possibility that your results will shuffle from page to page since you're just using your database's inherent sorting. So, let's add a quick sort to ours so we don't run that risk. We can also remove our <code>createdBy</code> filter and preload; we don't actually need them here.</p><pre><code class="language-ts">// app/controllers/challenges_controller.ts
export default class ChallengesController {
  /**
   * Display a list of resource
   */
  async index({ request, view }: HttpContext) {
    const { page, perPage } = await request.validateUsing(challengeIndexValidator)
    const challenges = await Challenge.query()
++      .orderBy('createdAt', 'desc')
      .paginate(page, perPage)

    challenges.baseUrl(urlFor('challenges.index'))
    challenges.queryString({ page, perPage })

    return view.render('pages/challenges/index', { challenges })
  }

  // ...
}</code></pre><p>Similar to <code>firstOrFail()</code>, the <code>paginate()</code> method is a terminating method that stops our ability to chain off the query builder further. So, we'll add our order by before calling paginate. Then, we'll sort by <code>createdAt</code> in <code>desc</code> to order our challenges from newest to oldest.</p><p>Jumping back into our <code>challenges.index</code> page, let's add our individual link numbers using <a target="_blank" rel="nofollow noopener noreferrer" href="https://lucid.adonisjs.com/docs/pagination#getting-urls-for-a-range-of-pages">the </a><code>getUrlsForRange()</code><a target="_blank" rel="nofollow noopener noreferrer" href="https://lucid.adonisjs.com/docs/pagination#getting-urls-for-a-range-of-pages"> method</a>.</p><pre><code class="language-edge">// resources/views/pages/challenges/index.edge
@layout()

  &lt;div&gt;
    {{-- ... --}}

    @challenge.grid() 
      @each(challenge in challenges)
        @!challenge.gridItem({ challenge }) 
      @endeach
    @end
    
    &lt;div style="display: flex; gap: .25rem;"&gt;
      @if (challenges.currentPage &gt; 1)
        &lt;a href="{{ challenges.getPreviousPageUrl() }}"&gt;Prev&lt;/a&gt;
      @endif

++      @each (anchor in challenges.getUrlsForRange(1, challenges.lastPage))
++        &lt;a href="{{ anchor.url }}"&gt;{{ anchor.page }}&lt;/a&gt;
++      @endeach

      @if (challenges.hasMorePages)
        &lt;a href="{{ challenges.getNextPageUrl() }}"&gt;Next&lt;/a&gt;
      @endif
    &lt;/div&gt;
  &lt;/div&gt;

@end</code></pre><p>The <code>getUrlsForRange()</code> method takes in the page range we want to generate URLs for. If we start at 1 and go to the last page, we'll generate the full gamut of URLs needed to get through all our pages of data. This method then returns:</p><pre><code class="language-ts">{ url: string; page: number; isActive: boolean }[]</code></pre><p>If the generated URL is for the currently displayed page, then <code>isActive</code> will be true. So, we could use this to highlight the active page.</p><pre><code class="language-edge">// resources/views/pages/challenges/index.edge
@layout()

  &lt;div&gt;
    {{-- ... --}}

    @challenge.grid() 
      @each(challenge in challenges)
        @!challenge.gridItem({ challenge }) 
      @endeach
    @end
    
    &lt;div style="display: flex; gap: .25rem;"&gt;
      @if (challenges.currentPage &gt; 1)
        &lt;a href="{{ challenges.getPreviousPageUrl() }}"&gt;Prev&lt;/a&gt;
      @endif

      @each (anchor in challenges.getUrlsForRange(1, challenges.lastPage))
++        &lt;a href="{{ anchor.url }}" style="color: {{ anchor.isActive ? 'blue' : null }}"&gt;
          {{ anchor.page }}
        &lt;/a&gt;
      @endeach

      @if (challenges.hasMorePages)
        &lt;a href="{{ challenges.getNextPageUrl() }}"&gt;Next&lt;/a&gt;
      @endif
    &lt;/div&gt;
  &lt;/div&gt;

@end</code></pre><p>Now, whatever page we're actively on will be blue instead of a dark gray.</p><p>Okay, last thing. As you go from page to page, notice how our total available points is changing? This is because it's the total available points for the current page of challenges instead of all challenges. Let's query a sum of our total available points to fix this.</p><pre><code class="language-ts">// app/controllers/challenges_controller.ts
export default class ChallengesController {
  /**
   * Display a list of resource
   */
  async index({ request, view }: HttpContext) {
    const { page, perPage } = await request.validateUsing(challengeIndexValidator)
++    const totalPoints = await Challenge.query().sum('points', 'total').firstOrFail()
    const challenges = await Challenge.query()
      .orderBy('createdAt', 'desc')
      .paginate(page, perPage)

    challenges.baseUrl(urlFor('challenges.index'))
    challenges.queryString({ page, perPage })

++    return view.render('pages/challenges/index', { challenges, totalPoints })
  }

  // ...
}</code></pre><p>Here we're using the <code>sum()</code> query builder method to sum all row's <code>points</code> column into a single value. This method also accepts the name/alias as the second argument, so we're calling it <code>total</code>. We're using the model query builder, so our sum will be at <code>totalPoints.$extras.total</code>. If we'd rather just get the value, we can instead use the <a target="_blank" rel="nofollow noopener noreferrer" href="https://lucid.adonisjs.com/docs/select-query-builder">database query builder</a>.</p><pre><code class="language-ts">// app/controllers/challenges_controller.ts
import Challenge from '#models/challenge'
import { challengeIndexValidator, challengeValidator } from '#validators/challenge'
import type { HttpContext } from '@adonisjs/core/http'
import { urlFor } from '@adonisjs/core/services/url_builder'
++import db from '@adonisjs/lucid/services/db'

export default class ChallengesController {
  /**
   * Display a list of resource
   */
  async index({ request, view }: HttpContext) {
    const { page, perPage } = await request.validateUsing(challengeIndexValidator)
++    const { totalPoints } = await db.from('challenges').sum('points', 'totalPoints').firstOrFail()
    const challenges = await Challenge.query()
      .orderBy('createdAt', 'desc')
      .paginate(page, perPage)

    challenges.baseUrl(urlFor('challenges.index'))
    challenges.queryString({ page, perPage })

++    return view.render('pages/challenges/index', { challenges, totalPoints })
  }

  // ...
}</code></pre><p>Remember, this is a lower-level query builder that doesn't go through our models. So, we need to specify tables and columns exactly as they're named in our database. To query from a table, we can use the <code>from(tableName)</code> method. Since this doesn't go through our models, we get back an object representation of what we have queried/selected. Meaning, we can destructure our <code>totalPoints</code> sum directly from our results for use.</p><p>Great, all that's left is to swap that into our page! This also removes the need for our template logic to aggregate this, so we can get rid of the <code>partials/challenge/available_points</code> partial we created, as that's no longer needed.</p><pre><code class="language-edge">// resources/views/pages/challenges/index.edge
@layout()

  &lt;div&gt;
    @if (session.has('success'))
      &lt;div class="alert alert-success"&gt;
        {{ session.get('success') }}
      &lt;/div&gt;
    @endif

    &lt;div class="hero"&gt;
      &lt;h1&gt;Challenges&lt;/h1&gt;

++      &lt;div&gt;
++        Total Points Available: {{ totalPoints.toLocaleString() }}
++      &lt;/div&gt;

      &lt;div&gt;
        Total Challenges: {{ challenges.total }}
      &lt;/div&gt;
  
      &lt;a href="{{ route('challenges.create') }}" class="button"&gt;Create a new challenge&lt;/a&gt;
    &lt;/div&gt;

    {{-- ... --}}
  &lt;/div&gt;

@end</code></pre><p>Since the sum is a number, we can also format the number (ex, 1,500), using the <code>toLocaleString()</code> method.</p>]]></content:encoded>
      </item>
      <item>
        <title><![CDATA[Query Builder Basics]]></title>
        <link>https://adocasts.com/lessons/query-builder-basics</link>
        <guid>https://adocasts.com/lessons/query-builder-basics</guid>
        <pubDate>Fri, 22 May 2026 12:00:00 +0000</pubDate>
        <description><![CDATA[Learn how to build complex SQL queries with where clauses, selects, joins, and more using method chaining with Lucid's Model and Database Query Builder in AdonisJS.]]></description>
        <content:encoded><![CDATA[<p>Query builders allow us to build everything from simple to complex queries consisting of where clauses, selects, joins, and more. Additionally, as we'll see in the next lesson, they also allow us to integrate with our model's relationships.</p><p>They allow us to build these queries using an easy-to-read method chain in TypeScript. Lucid then takes the query we've built, translates it to SQL, executes it, and returns our results to us.</p><p>There are two different, high-level, types of query builders:</p><ol><li><p><a target="_blank" rel="nofollow noopener noreferrer" href="https://lucid.adonisjs.com/docs/select-query-builder">Database query builder</a> (select/insert)</p></li><li><p><a target="_blank" rel="nofollow noopener noreferrer" href="https://lucid.adonisjs.com/docs/model-query-builder">Model query builder</a></p></li></ol><p>The <a target="_blank" rel="nofollow noopener noreferrer" href="https://lucid.adonisjs.com/docs/model-query-builder">model query builder</a> uses our models as a translation layer between our query and the database. Converting, for example, camel case property usage in our queries to the appropriate snake case column name on our table. They also allow us to use our model relationships as well. Unless otherwise specified, the model query builder will always return an instance of the model as well.</p><p>The <a target="_blank" rel="nofollow noopener noreferrer" href="https://lucid.adonisjs.com/docs/select-query-builder">database query builder</a> is a more raw query builder that doesn't integrate with our models at all. Instead, it goes directly to the database. It also requires manual typing if you wish for it to have a type other than any.</p><h2>Building Queries</h2><p>Let's start simple by replacing the <code>Challenges.all()</code> query from our <code>ChallengesController.index</code> method to instead use our <code>Challenge</code> model's query builder. To start a query builder, all we need to do is call the <code>query()</code> method on our model.</p><pre><code class="language-ts">// app/controllers/challenges_controller.ts
export default class ChallengesController {
  /**
   * Display a list of resource
   */
  async index({ view }: HttpContext) {
++    const list = await Challenge.query()
    return view.render('pages/challenges/index', { challenges: list })
  }

  // ...
}</code></pre><p>What we've just done is a one-for-one swap, as both <code>Challenge.all()</code> and <code>Challenge.query()</code> will return all records in our <code>challenges</code> table. However, unlike <code>all()</code>, we can chain additional methods off of <code>query()</code> to alter our results.</p><p>For example, if we wanted to limit our challenges to those with a point value higher than 50, we could add a <code>where()</code> method. This, in turn, adds a <code>WHERE</code> clause to the SQL our builder will generate and execute.</p><pre><code class="language-ts">// app/controllers/challenges_controller.ts
export default class ChallengesController {
  /**
   * Display a list of resource
   */
  async index({ view }: HttpContext) {
++    const list = await Challenge.query().where('points', '&gt;', 50)
    return view.render('pages/challenges/index', { challenges: list })
  }

  // ...
}</code></pre><p>The first argument is the column/property name, the second argument is the comparison operator, and the third is the comparison value. This will add a <code>WHERE points &gt; 50</code> clause to the underlying SQL that'll be executed, limiting the returned challenges that we can see reflected on our page. We can also sort our results as well! Let's sort our challenges by their text.</p><pre><code class="language-ts">// app/controllers/challenges_controller.ts
export default class ChallengesController {
  /**
   * Display a list of resource
   */
  async index({ view }: HttpContext) {
++    const list = await Challenge.query().where('points', '&gt;', 50).orderBy('text')
    return view.render('pages/challenges/index', { challenges: list })
  }

  // ...
}</code></pre><p>The default sort direction will be ascending (A-Z). We can switch this to descending (Z-A) if we prefer, though.</p><pre><code class="language-ts">// app/controllers/challenges_controller.ts
export default class ChallengesController {
  /**
   * Display a list of resource
   */
  async index({ view }: HttpContext) {
++    const list = await Challenge.query().where('points', '&gt;', 50).orderBy('text', 'desc')
    return view.render('pages/challenges/index', { challenges: list })
  }

  // ...
}</code></pre><p>Let's filter a little more, by adding an additional <code>where()</code> method to our query, we're saying to get challenges where the points are greater than 50, and the text is whatever the first item on our page is, like "input solid-state feed."</p><pre><code class="language-ts">// app/controllers/challenges_controller.ts
export default class ChallengesController {
  /**
   * Display a list of resource
   */
  async index({ view }: HttpContext) {
    const list = await Challenge.query()
      .where('points', '&gt;', 50)
++      .where('text', 'input solid-state feed')
      .orderBy('text', 'desc')

    return view.render('pages/challenges/index', { challenges: list })
  }

  // ...
}</code></pre><p>With the <code>where()</code> method, when we only provide two arguments, column and value, an equality check will be performed by default. Note, similar to the <code>findBy()</code> method, this can also be an object with key/value pairs if all you need are equality checks.</p><pre><code class="language-ts">// app/controllers/challenges_controller.ts
export default class ChallengesController {
  /**
   * Display a list of resource
   */
  async index({ view }: HttpContext) {
    const list = await Challenge.query()
      .where('points', '&gt;', 50)
++      .where({ text: 'input solid-state feed' })
      .orderBy('text', 'desc')

    return view.render('pages/challenges/index', { challenges: list })
  }

  // ...
}</code></pre><p>If we need a likeness check, we can pass <code>LIKE</code> or <code>ILIKE</code> as the comparison operator and add the percent sign (<code>%</code>) as needed to our value.</p><pre><code class="language-ts">// app/controllers/challenges_controller.ts
export default class ChallengesController {
  /**
   * Display a list of resource
   */
  async index({ view }: HttpContext) {
    const list = await Challenge.query()
      .where('points', '&gt;', 50)
++      .where('text', 'LIKE', 'input%')
      .orderBy('text', 'desc')

    return view.render('pages/challenges/index', { challenges: list })
  }

  // ...
}</code></pre><p>This will give us all challenges where the points are greater than 50, and the text starts with "input", for example. So far, we've been doing <code>AND WHERE</code> statements, but what if we need <code>OR WHERE</code>? Well, there's an <code>orWhere()</code> method just for that, and it works just like <code>where()</code>, changing just the comparison type.</p><pre><code class="language-ts">// app/controllers/challenges_controller.ts
export default class ChallengesController {
  /**
   * Display a list of resource
   */
  async index({ view }: HttpContext) {
    const list = await Challenge.query()
      .where('points', '&gt;', 50)
++      .orWhere('text', 'LIKE', 'input%')
      .orderBy('text', 'desc')

    return view.render('pages/challenges/index', { challenges: list })
  }

  // ...
}</code></pre><p>So, this will now give us challenges with points greater than 50 or text starting with <code>input</code>. By the way, if you prefer the explicitness of the <code>orWhere()</code> and would prefer to have that with and comparisons, there is also an <code>andWhere()</code> you can use as an alternative.</p><p>Okay, what about mixing and/or statements? For this, the <code>where()</code> methods can also accept a callback function that is provided a nested version of the query builder. This nested version will be translated to SQL wrapped in parentheses, allowing us to build more complex where clauses.</p><pre><code class="language-ts">// app/controllers/challenges_controller.ts
export default class ChallengesController {
  /**
   * Display a list of resource
   */
  async index({ view }: HttpContext) {
    const list = await Challenge.query()
      .where('points', '&gt;', 50)
++      .where((query) =&gt; query
++        .where('text', 'LIKE', 'input%')
++        .orWhere('text', 'LIKE', 'index%')
++      )
      .orderBy('text', 'desc')

    return view.render('pages/challenges/index', { challenges: list })
  }

  // ...
}</code></pre><p>Now, we'll get challenges with points greater than 50 and having a text value starting with <code>input</code> or <code>index</code>. In turn, generating SQL like:</p><pre><code class="language-sql">SELECT
  *
FROM
  challenges
WHERE
    points &gt; 50
AND (
     text LIKE 'input%'
  OR text LIKE 'index%'
)</code></pre><p>We aren't using the <code>createdAt</code> nor <code>updatedAt</code> at the moment, so if we wanted to save a little, we could omit those columns from our query using the <code>select()</code> method.</p><pre><code class="language-ts">// app/controllers/challenges_controller.ts
export default class ChallengesController {
  /**
   * Display a list of resource
   */
  async index({ view }: HttpContext) {
    const list = await Challenge.query()
      .where('points', '&gt;', 50)
      .where((query) =&gt; query
        .where('text', 'LIKE', 'input%')
        .orWhere('text', 'LIKE', 'index%')
      )
++      .select('id', 'text', 'points')
      .orderBy('text', 'desc')

    return view.render('pages/challenges/index', { challenges: list })
  }

  // ...
}</code></pre><p>This will still return an instance of our model, but will now only have the <code>id</code>, <code>text</code>, and <code>points</code> properties populated. Also, unlike SQL, note that it doesn't matter what order we add our query methods in. In SQL, the <code>SELECT</code> comes first, but we can put that after our where clauses as we have above with the query builder. It won't impact the SQL it ultimately generates.</p><p>If we need to sort by more than one column, we can do that as well by passing an array into the <code>orderBy()</code> method. All we need to do is specify the <code>column</code> and <code>order</code> for each item we'd like to sort by. It'll then sort in the order of the items in our array.</p><pre><code class="language-ts">// app/controllers/challenges_controller.ts
export default class ChallengesController {
  /**
   * Display a list of resource
   */
  async index({ view }: HttpContext) {
    const list = await Challenge.query()
      .where('points', '&gt;', 50)
      .where((query) =&gt; query
        .where('text', 'LIKE', 'input%')
        .orWhere('text', 'LIKE', 'index%')
      )
      .select('id', 'text', 'points')
++      .orderBy([
++        { column: 'text', order: 'desc' },
++        { column: 'points', order: 'asc' },
++      ])

    return view.render('pages/challenges/index', { challenges: list })
  }

  // ...
}</code></pre><p>This will now sort by our text in descending order, then by our points in ascending order.</p><h2>Joins</h2><p>Joins allow us to include another table's records in our query via our relationship columns. Note, these are not the model relationships we'll be discussing in the next lesson, but rather a direct query statement being added into our generated SQL. In most cases, model relationships will eliminate the need to use joins altogether.</p><p>To add a join, <a target="_blank" rel="nofollow noopener noreferrer" href="https://lucid.adonisjs.com/docs/select-query-builder#joins">we can use the </a><code>join()</code><a target="_blank" rel="nofollow noopener noreferrer" href="https://lucid.adonisjs.com/docs/select-query-builder#joins"> method</a>. This accepts the table we want to join, followed by the columns we want to compare to match the relationship rows. For example, in a join, we want to map <code>challenges.creator_id</code> to <code>users.id</code>. Joins are lower-level and aren't model away, so we do need to use the column names from our table. Once joined, we can use columns as needed from the joined table.</p><pre><code class="language-ts">// app/controllers/challenges_controller.ts
export default class ChallengesController {
  /**
   * Display a list of resource
   */
  async index({ view }: HttpContext) {
    const list = await Challenge.query()
      .where('points', '&gt;', 50)
      .where((query) =&gt; query
        .where('text', 'LIKE', 'input%')
        .orWhere('text', 'LIKE', 'index%')
      )
++      .join('users', 'users.id', 'challenges.creator_id')
++      .select('challenges.id', 'text', 'points', 'users.full_name')
++      .orderBy('users.full_name')

    return view.render('pages/challenges/index', { challenges: list })
  }

  // ...
}</code></pre><p>Here we're joining our <code>users</code> table with our <code>challenges</code> table using a comparison between the <code>users.id</code> and the <code>challenges.creator_id</code>. We're selecting and ordering by the creator's <code>full_name</code> from our <code>users</code> table. I've prefixed <code>full_name</code> with <code>users.full_name</code>, not because it needs to be, but rather because it makes it clear that the column is coming from a join. It'll make it easier to know the column needs to be removed if the join is ever removed.</p><p>I am, however, prefixing our select's <code>id</code> with <code>challenges.id</code> for a very specific reason. Both our <code>users</code> and <code>challenges</code> table have an <code>id</code> column. Since both <code>id</code> columns are now present within our query, we need to be explicit about which we're referencing. Without it, we'd get an ambiguous error as SQL wouldn't know whether to use <code>challenges.id</code> or <code>users.id</code>.</p><p>Despite our joining in our <code>users</code> table and selecting the <code>users.full_name</code> column, we will still get back instances of our <code>Challenge</code> model for our result. Lucid will populate the <code>Challenge</code> model's columns as usual, and put anything extra from the query that's been selected into an <code>$extras</code> object.</p><p>So, if we were to console log the <code>$extras</code> from each of our models, we'd see the creator name for each of our challenges!</p><pre><code class="language-ts">// app/controllers/challenges_controller.ts
export default class ChallengesController {
  /**
   * Display a list of resource
   */
  async index({ view }: HttpContext) {
    const list = await Challenge.query()
      .where('points', '&gt;', 50)
      .where((query) =&gt; query
        .where('text', 'LIKE', 'input%')
        .orWhere('text', 'LIKE', 'index%')
      )
      .join('users', 'users.id', 'challenges.creator_id')
      .select('challenges.id', 'text', 'points', 'users.full_name')
      .orderBy('users.full_name')

++    console.log({ extras: list.map((item) =&gt; item.$extras) })

    return view.render('pages/challenges/index', { challenges: list })
  }

  // ...
}</code></pre><p>If you want to include the creator on each challenge or filter by the creator, we'll see a much cleaner way to do that using model relationships in the next lesson.</p><h2>Limiting Results</h2><p>What if we're after a specific number of records? For example, in our <code>ChallengesController.show</code> method, we only want one result, so how could we do that using the query builder? First, let's replace our <code>Challenge.findBy()</code> with our query builder.</p><pre><code class="language-ts">// app/controllers/challenges_controller.ts
export default class ChallengesController {
  // ...

  /**
   * Show individual record
   */
  async show({ view, params }: HttpContext) {
++    const challenge = await Challenge.query().where('id', params.id)
    const editUrl = urlFor('challenges.edit', { id: params.id })

    return view.render('pages/challenges/show', { challenge, editUrl })
  }

  // ...
}</code></pre><p>Then, for starters, to restrict the number of results, we can use the query builder's <code>limit()</code> method. This takes in the number of records we want to get back, and it will query for the first X amount up to our limit. So, to get just one, we could use <code>limit(1)</code>.</p><pre><code class="language-ts">// app/controllers/challenges_controller.ts
export default class ChallengesController {
  // ...

  /**
   * Show individual record
   */
  async show({ view, params }: HttpContext) {
++    const challenge = await Challenge.query().where('id', params.id).limit(1)
    const editUrl = urlFor('challenges.edit', { id: params.id })

    return view.render('pages/challenges/show', { challenge, editUrl })
  }

  // ...
}</code></pre><p>However, <code>limit()</code> will still return an array. If we're after just one result, we have better options, like the <code>first()</code> method. This will apply a limit of one and give us our model instance back directly, instead of it being wrapped in an array!</p><pre><code class="language-ts">// app/controllers/challenges_controller.ts
export default class ChallengesController {
  // ...

  /**
   * Show individual record
   */
  async show({ view, params }: HttpContext) {
++    const challenge = await Challenge.query().where('id', params.id).first()
    const editUrl = urlFor('challenges.edit', { id: params.id })

    return view.render('pages/challenges/show', { challenge, editUrl })
  }

  // ...
}</code></pre><p>Similar to our static model method, there is also <code>firstOrFail()</code>. This will attempt to find the first result, and if it can't, will throw a 404 Not Found exception. This is more appropriate for our <code>ChallengesController.show</code> as if a challenge can't be found, then there's nothing to show.</p><pre><code class="language-ts">// app/controllers/challenges_controller.ts
export default class ChallengesController {
  // ...

  /**
   * Show individual record
   */
  async show({ view, params }: HttpContext) {
++    const challenge = await Challenge.query().where('id', params.id).firstOrFail()
    const editUrl = urlFor('challenges.edit', { id: params.id })

    return view.render('pages/challenges/show', { challenge, editUrl })
  }

  // ...
}</code></pre><h2>Building with the Builder</h2><p>Finally, we don't need to directly chain methods consecutively with the query builder. In fact, the query won't actually execute as SQL until it is awaited or we call a terminating method, like <code>firstOrFail()</code>. This gives us the ability to systematically build on the query as needed. A simple example, but the following gives you a sense of what I mean.</p><pre><code class="language-ts">// app/controllers/challenges_controller.ts
export default class ChallengesController {
  // ...

  /**
   * Show individual record
   */
  async show({ view, params }: HttpContext) {
    // start a query builder
++    const query = Challenge.query()

    // add statements as needed
++    query.where('id', params.id)
    
    // firstOrFail is terminating, meaning we can't chain further on it
    // it'll return Promise&lt;Challenge&gt;
++    const challenge = await query.firstOrFail() 

    const editUrl = urlFor('challenges.edit', { id: params.id })

    return view.render('pages/challenges/show', { challenge, editUrl })
  }

  // ...
}</code></pre><p>Let's move to our index method for a more detailed look.</p><pre><code class="language-ts">// app/controllers/challenges_controller.ts
export default class ChallengesController {
  /**
   * Display a list of resource
   */
  async index({ view }: HttpContext) {
    // start a query builder
    const query = Challenge.query()

    // add or chain statements as needed
    query.where('points', '&gt;', 50)
    query.where((q) =&gt; q.where('text', 'LIKE', 'input%').orWhere('text', 'LIKE', 'index%'))
    query.join('users', 'users.id', 'challenges.creator_id')
    query.select('challenges.id', 'text', 'points', 'users.full_name')
    query.orderBy('users.full_name')

    // await the query to execute
    const challenges = await query

    return view.render('pages/challenges/index', { challenges })
  }

  // ...
}</code></pre><p>Here we're getting the query builder and adding our statements to it individually, one at a time. Though these statements return back our query builder, we don't need to overwrite <code>query</code>, they'll be internally applied to the query we're building. Finally, we aren't using any terminating statements, so the query is translated to SQL and executed only when we await the query.</p><p>Okay - I'm going to undo all of that to get us back to where we were.</p><pre><code class="language-ts">// app/controllers/challenges_controller.ts
export default class ChallengesController {
  /**
   * Display a list of resource
   */
  async index({ view }: HttpContext) {
    const challenges = await Challenge.query()
      .where('points', '&gt;', 50)
      .where((query) =&gt; query
        .where('text', 'LIKE', 'input%')
        .orWhere('text', 'LIKE', 'index%')
      )
      .join('users', 'users.id', 'challenges.creator_id')
      .select('challenges.id', 'text', 'points', 'users.full_name')
      .orderBy('users.full_name')

    return view.render('pages/challenges/index', { challenges })
  }

  // ...

  /**
   * Show individual record
   */
  async show({ view, params }: HttpContext) {
    const challenge = await Challenge.query().where('id', params.id).firstOrFail()
    const editUrl = urlFor('challenges.edit', { id: params.id })

    return view.render('pages/challenges/show', { challenge, editUrl })
  }

  // ...
}</code></pre>]]></content:encoded>
      </item>
      <item>
        <title><![CDATA[Working with Model Relationships]]></title>
        <link>https://adocasts.com/lessons/working-with-relationships</link>
        <guid>https://adocasts.com/lessons/working-with-relationships</guid>
        <pubDate>Fri, 22 May 2026 12:00:00 +0000</pubDate>
        <description><![CDATA[Learn the difference between eager and lazy loading relationships in AdonisJS. We'll also learn about querying nested relationships, filtering by relationships, and more.]]></description>
        <content:encoded><![CDATA[<p>In the last lesson, we saw how we can join relationships together using the query builder, but in most cases, we can lean on model relationships instead to simplify our query logic. Model relationships can be used for any relationship defined within our models, and we can nest relationships as much as needed.</p><h2>Eagerly Loading Relationships</h2><p>To start, on our <code>challenges.show</code> page, we may want to show the details of the person who created the challenge. To do this using our <code>Challenge</code> model's relationship to the creator, we can eagerly load the relationship onto the queried results using the <code>preload()</code> method from our query builder.</p><pre><code class="language-ts">// app/controllers/challenges_controller.ts
export default class ChallengesController {
  // ...

  /**
   * Show individual record
   */
  async show({ view, params }: HttpContext) {
    const challenge = await Challenge.query()
++      .preload('createdBy')
      .where('id', params.id)
      .firstOrFail()
      
    const editUrl = urlFor('challenges.edit', { id: params.id })

    return view.render('pages/challenges/show', { challenge, editUrl })
  }

  // ...
}</code></pre><p>With model relationships, the entire relationship we've loaded is populated into its own model via the relationship property. For example, by preloading the <code>createdBy</code> relationship, we can now access the creator's <code>User</code> model instance via <code>challenge.createdBy</code> directly from our query's result.</p><p>Furthermore, the loaded relationship gets its own query builder as well, which we can access via the second argument. So, if we wanted to just select the <code>firstName</code> and <code>id</code>, we can do that!</p><pre><code class="language-ts">// app/controllers/challenges_controller.ts
export default class ChallengesController {
  // ...

  /**
   * Show individual record
   */
  async show({ view, params }: HttpContext) {
    const challenge = await Challenge.query()
++      .preload('createdBy', (query) =&gt; query.select('id', 'fullName'))
      .where('id', params.id)
      .firstOrFail()
      
    const editUrl = urlFor('challenges.edit', { id: params.id })

    return view.render('pages/challenges/show', { challenge, editUrl })
  }

  // ...
}</code></pre><p>This type of relationship loading, using <code>preload()</code> is called eager-loading because the relationships are immediately requested. They're also requested in a manner that minimizes the number of queries needed and accounts for the N+1 problem.</p><p>For example, if you have 50 challenges and want to load the creator for each, the N+1 problem would be fetching the challenges, then looping over them to query their creators, one by one, using a separate query for each, for a total of 51 queries. An easy bottleneck in any application. Eager-loading solves this by instead using just 2 queries by:</p><ol><li><p>Querying for the challenges</p></li><li><p>Grabbing all the challenge's <code>creatorIds</code></p></li><li><p>Fetching all the creators in a single query</p></li><li><p>Populating the related creator into each challenge</p></li></ol><h2>Lazily Loading Relationships</h2><p>Though <code>preload()</code> can be more efficient with lists of data, there's no real benefit when working with singular records, like we are in our <code>show</code> and <code>edit</code> handlers. In these situations, we may choose to lazily-load the relationship by loading it at a point dissociated with the original query. This comes in particularly handy with our static model query methods, like <code>findOrFail()</code>, because it gives us a way to populate a relationship for these queries using the <code>load()</code> method.</p><pre><code class="language-ts">// app/controllers/challenges_controller.ts
export default class ChallengesController {
  // ...

  /**
   * Edit individual record
   */
  async edit({ params, view }: HttpContext) {
    // find the challenge being edited by its id
    const challenge = await Challenge.findOrFail(params.id)

++    await challenge.load('createdBy')

    // pass the challenge to the view
    return view.render('pages/challenges/edit', { challenge })
  }

  // ...
}</code></pre><p>The <code>load()</code> method works just like <code>preload()</code>, the only difference being how the relationship is loaded; <code>preload()</code> is for eager-loading and <code>load()</code> is for lazy-loading.</p><h2>Counts &amp; Aggregates</h2><p>We can also use dedicated methods to load relationship counts and aggregates as well. When lazy-loading, the methods to do so are <code>loadCount()</code> and <code>loadAggregate()</code>. For example, if we want to include the number of participants for a challenge, we can use <code>loadCount('participants')</code>.</p><pre><code class="language-ts">// app/controllers/challenges_controller.ts
export default class ChallengesController {
  // ...

  /**
   * Edit individual record
   */
  async edit({ params, view }: HttpContext) {
    // find the challenge being edited by its id
    const challenge = await Challenge.findOrFail(params.id)

    await challenge.load('createdBy')
++    await challenge.loadCount('participants')

    // pass the challenge to the view
    return view.render('pages/challenges/edit', { challenge })
  }

  // ...
}</code></pre><p>There aren't dedicated columns for counts nor aggregates, so again, these will be populated onto the <code>$extras</code> object of our returned model instance. They're also given a default name from a snake case concatenation of the relationship property name and count, for example, our participant count will be <code>participants_count</code> and can be accessed via <code>challenge.$extras.participants_count</code>.</p><p>We can also give it a name of our own, for example, let's say we ultimately want three counts:</p><ol><li><p>Total number of participants</p></li><li><p>Total number of participants who have not completed the challenge</p></li><li><p>Total number of participants who have completed the challenge</p></li></ol><p>To start, we'll add two additional counts and filter them by the pivot table's <code>completed_at</code> column. To do this, there are several special purposed <code>wherePivot()</code> methods to filter a many-to-many relationship by their pivot table data. For this particular check, we'll use <code>whereNullPivot()</code> and <code>whereNotNullPivot()</code>.</p><pre><code class="language-ts">// app/controllers/challenges_controller.ts
export default class ChallengesController {
  // ...

  /**
   * Edit individual record
   */
  async edit({ params, view }: HttpContext) {
    // find the challenge being edited by its id
    const challenge = await Challenge.findOrFail(params.id)

    await challenge.load('createdBy')
    await challenge.loadCount('participants')
++    await challenge.loadCount('participants', (query) =&gt; query.whereNullPivot('completed_at'))
++    await challenge.loadCount('participants', (query) =&gt; query.whereNotNullPivot('completed_at'))

    // pass the challenge to the view
    return view.render('pages/challenges/edit', { challenge })
  }

  // ...
}</code></pre><p>Next, we can give them applicable names via their callback query builders by adding an <code>as()</code> method to manually give them a name applicable to their purpose.</p><pre><code class="language-ts">// app/controllers/challenges_controller.ts
export default class ChallengesController {
  // ...

  /**
   * Edit individual record
   */
  async edit({ params, view }: HttpContext) {
    // find the challenge being edited by its id
    const challenge = await Challenge.findOrFail(params.id)

    await challenge.load('createdBy')
    await challenge.loadCount('participants')

++    await challenge.loadCount('participants', (query) =&gt; 
++      query.whereNullPivot('completed_at').as('not_completed_count')
++    )

++    await challenge.loadCount('participants', (query) =&gt; 
++      query.whereNotNullPivot('completed_at').as('completed_count')
++    )

    // pass the challenge to the view
    return view.render('pages/challenges/edit', { challenge })
  }

  // ...
}</code></pre><p>With this, we'll now get three separate counts for our challenges!</p><ol><li><p><code>participants_count</code> - the total number</p></li><li><p><code>not_completed_count</code> - the total number who haven't completed the challenge</p></li><li><p><code>completed_count</code> - the total number who have completed the challenge</p></li></ol><p>We probably don't need these counts on our edit page, though, so let's instead do this same thing using our query builder on our <code>show</code> handler! Instead of <code>loadCount()</code> all we need to do is use <code>withCount()</code> instead, everything else remains the same!</p><pre><code class="language-ts">// app/controllers/challenges_controller.ts
export default class ChallengesController {
  // ...

  /**
   * Show individual record
   */
  async show({ view, params }: HttpContext) {
    const challenge = await Challenge.query()
      .preload('createdBy', (query) =&gt; query.select('id', 'fullName'))
++      .withCount('participants')
++      .withCount('participants', (query) =&gt;
++        query.whereNullPivot('completed_at').as('not_completed_count')
++      )
++      .withCount('participants', (query) =&gt;
++        query.whereNotNullPivot('completed_at').as('completed_count')
++      )
      .where('id', params.id)
      .firstOrFail()
      
    const editUrl = urlFor('challenges.edit', { id: params.id })

    return view.render('pages/challenges/show', { challenge, editUrl })
  }

  // ...
}</code></pre><p>Now, when using the aggregate version of these methods, like <code>withAggregate()</code> it's on us to perform the type of aggregation we're after within the callback query builder. This gives us a fluid way to build counts, averages, min, max, and other aggregates as needed. For example, let's replace our <code>withCount('participants')</code> with an aggregate usage.</p><pre><code class="language-ts">// app/controllers/challenges_controller.ts
export default class ChallengesController {
  // ...

  /**
   * Show individual record
   */
  async show({ view, params }: HttpContext) {
    const challenge = await Challenge.query()
      .preload('createdBy', (query) =&gt; query.select('id', 'fullName'))
++      .withAggregate('participants', (query) =&gt; query.count('*').as('participants_count'))
      .withCount('participants', (query) =&gt;
        query.whereNullPivot('completed_at').as('not_completed_count')
      )
      .withCount('participants', (query) =&gt;
        query.whereNotNullPivot('completed_at').as('completed_count')
      )
      .where('id', params.id)
      .firstOrFail()
      
    const editUrl = urlFor('challenges.edit', { id: params.id })

    return view.render('pages/challenges/show', { challenge, editUrl })
  }

  // ...
}</code></pre><p>Here we're not filtering at all, though we could. Then, we're counting the result of all columns using <code>*</code>. Finally, aggregates must be given a name, so we're manually setting it to <code>participants_count</code>.</p><p>Any of these can be freely used on our show page, so let's add the challenge's creator and participants.</p><pre><code class="language-edge">// resources/views/pages/challenges/show.edge
@layout()

  &lt;div class="hero"&gt;
    &lt;h1&gt;{{ challenge.text }}&lt;/h1&gt;
    &lt;p&gt;Points: {{ challenge.points }}&lt;/p&gt;
++    &lt;p&gt;Created by: {{ challenge.createdBy.fullName }}&lt;/p&gt;
++    &lt;p&gt;Participants: {{ challenge.$extras.participants_count }} ({{ challenge.$extras.completed_count }} completed)&lt;/p&gt;

    &lt;a href="{{ editUrl }}" class="button"&gt;Edit Challenge&lt;/a&gt;
    &lt;button type="submit" form="destroy" class="button destructive"&gt;Delete&lt;/button&gt;
  &lt;/div&gt;

  @!form({ id: 'destroy', route: 'challenges.destroy', routeParams: { id: challenge.id }, method: 'DELETE' })

@end</code></pre><p>We didn't add any completed participants in our seeder, so it'll be all 0's across the board there for the moment.</p><h2>Eager Loading &amp; Joining</h2><p>Let's return to our <code>index</code> handler where we have our join. We now know that we can alternatively eager-load the relationship instead of joining. Meaning, if we do that, we can remove our <code>users.full_name</code> from the select list.</p><pre><code class="language-ts">// app/controllers/challenges_controller.ts
export default class ChallengesController {
  /**
   * Display a list of resource
   */
  async index({ view }: HttpContext) {
    const challenges = await Challenge.query()
      .where('points', '&gt;', 50)
      .where((q) =&gt; q.where('text', 'LIKE', 'input%').orWhere('text', 'LIKE', 'index%'))
++      .preload('createdBy')
++      .select('challenges.id', 'text', 'points')
      .join('users', 'users.id', 'challenges.creator_id')
      .orderBy('users.full_name')

    return view.render('pages/challenges/index', { challenges })
  }

  // ...
}</code></pre><p>However, since model relationships are queried for and populated post-query, they require the relationship's foreign key to be included within the select list. Otherwise, Lucid won't be able to populate the relationship. Without it, we'll see an exception like:</p><pre><code>Cannot preload "createdBy", value of "Challenge.creatorId" is undefined. Make sure to set "null" as the default value for foreign keys</code></pre><p>This can be easily solved by adding the <code>creatorId</code> into our select list of our query builder!</p><pre><code class="language-ts">// app/controllers/challenges_controller.ts
export default class ChallengesController {
  /**
   * Display a list of resource
   */
  async index({ view }: HttpContext) {
    const challenges = await Challenge.query()
      .where('points', '&gt;', 50)
      .where((q) =&gt; q.where('text', 'LIKE', 'input%').orWhere('text', 'LIKE', 'index%'))
++      .preload('createdBy')
++      .select('challenges.id', 'text', 'points', 'creatorId')
      .join('users', 'users.id', 'challenges.creator_id')
      .orderBy('users.full_name')

    return view.render('pages/challenges/index', { challenges })
  }

  // ...
}</code></pre><p>Also note, we're still joining here, which allows us to order by the joined relationship. This is one thing we can't do directly with model relationships, and are where using joins comes in handy. Since we are still joining, we still have the ambiguous <code>id</code> column and still need to reference <code>challenges.id</code> specifically in our select list. However, do note, if we omit our select list, Lucid will be able to handle the ambiguity on its own without issue!</p><pre><code class="language-ts">// app/controllers/challenges_controller.ts
export default class ChallengesController {
  /**
   * Display a list of resource
   */
  async index({ view }: HttpContext) {
    const challenges = await Challenge.query()
      .where('points', '&gt;', 50)
      .where((q) =&gt; q.where('text', 'LIKE', 'input%').orWhere('text', 'LIKE', 'index%'))
      .preload('createdBy')
      .join('users', 'users.id', 'challenges.creator_id')
      .orderBy('users.full_name')

    return view.render('pages/challenges/index', { challenges })
  }

  // ...
}</code></pre><h2>Filtering By Relationships</h2><p>Finally, if we need to filter our results by the relationship at all with the query builder, we can do that with ease using specialized where methods for relationships. For example, if we only want to include challenges from creators with a public profile, we could do something like:</p><pre><code class="language-ts">// app/controllers/challenges_controller.ts
export default class ChallengesController {
  /**
   * Display a list of resource
   */
  async index({ view }: HttpContext) {
    const challenges = await Challenge.query()
      .where('points', '&gt;', 50)
      .where((q) =&gt; q.where('text', 'LIKE', 'input%').orWhere('text', 'LIKE', 'index%'))
++      .whereHas('createdBy', (creator) =&gt;
++        creator.whereHas('profile', (profile) =&gt; profile.where('isPublic', true))
++      )
      .preload('createdBy')
      .join('users', 'users.id', 'challenges.creator_id')
      .orderBy('users.full_name')

    return view.render('pages/challenges/index', { challenges })
  }

  // ...
}</code></pre><p>This will limit our challenges to those where <code>challenge.createdBy.profile.isPublic</code> is <code>true</code>! Let's simplify this query so we can see exactly what the <code>whereHas()</code> and <code>preload()</code> methods are executed via the SQL that's executed! By the way, for any of these, you can find the executed SQL printed out in your terminal for convenience.</p><pre><code class="language-ts">// app/controllers/challenges_controller.ts
export default class ChallengesController {
  /**
   * Display a list of resource
   */
  async index({ view }: HttpContext) {
    const challenges = await Challenge.query()
      .whereHas('createdBy', (creator) =&gt;
        creator.whereHas('profile', (profile) =&gt; profile.where('isPublic', true))
      )
      .preload('createdBy')

    return view.render('pages/challenges/index', { challenges })
  }

  // ...
}</code></pre><p>First, we have the main query from our query builder with the relationship filtering via <code>whereHas()</code>. The SQL generated for this is:</p><pre><code class="language-sql">SELECT
  *
FROM
  `challenges`
WHERE
  EXISTS (
    SELECT
      *
    FROM
      `users`
    WHERE
      (
        EXISTS (
          SELECT
            *
          FROM
            `profiles`
          WHERE
            (`is_public` = ?)
            AND (`users`.`id` = `profiles`.`user_id`)
        )
      )
      AND (`users`.`id` = `challenges`.`creator_id`)
  ) [ true ]</code></pre><p>As you can see <code>whereHas()</code> directly filters the query using an <code>EXISTS</code> predicate! We can also see our where clause uses SQL parameterization to help protect us against injection attacks.</p><p>Then, we have the query run for our <code>preload()</code>. The SQL generated for this is:</p><pre><code class="language-sql">SELECT * FROM `users` WHERE `id` IN (?, ?, ?, ?, ?, ?, ?) [
  8, 1, 7, 3,
  6, 10, 9
]</code></pre><p>So, amongst our challenges being queried, we have seven distinct creators. Lucid queries these separately, then fills them into the <code>challenge.createdBy</code> property post-query to keep things efficient and again to solve the N+1 problem.</p>]]></content:encoded>
      </item>
      <item>
        <title><![CDATA[Factories & Seeders]]></title>
        <link>https://adocasts.com/lessons/factories-and-seeders</link>
        <guid>https://adocasts.com/lessons/factories-and-seeders</guid>
        <pubDate>Fri, 08 May 2026 11:00:00 +0000</pubDate>
        <description><![CDATA[Learn database seeders and factories in AdonisJS. Populate databases with preliminary data using factories and seeders.]]></description>
        <content:encoded><![CDATA[<p>If you'll recall from the last lesson, we hopped into a REPL session to make a role so we could create a user. It isn't ideal to have every teammate that pulls down our project do that manually. Instead it'd be great to have our application's roles created with a process similar to our migrations, that's where seeders come in.</p><p>Seeders allow us to populate our database with preliminary data. We might need preliminary data for a few different reasons, like:</p><ul><li><p>Default data required by an application, like our roles</p></li><li><p>Development environments, to get teammates up and running as quickly as possible</p></li><li><p>Resetting a demo environment to showcase our project to prospective clients</p></li></ul><p>We can use the Ace CLI's <code>make:seeder</code> command to easily add a seeder to our application. Let's make one called <code>default</code> we can use it for our roles.</p><pre><code class="language-bash">node ace make:seeder default
# DONE:    create database/seeders/default_seeder.ts</code></pre><p>Seeders can be found within the <code>database/seeders/</code> directory, and our new <code>DefaultSeeder</code> will look like below.</p><pre><code class="language-ts">import { BaseSeeder } from '@adonisjs/lucid/seeders'

export default class extends BaseSeeder {
  async run() {
    // Write your database queries inside the run method
  }
}</code></pre><p>Exactly as the comment states, all we need to do is create our preliminary data within the <code>run()</code> method. That <code>run()</code> method will then be run when our seeder is called. So, for our roles, we can create a user and an admin role.</p><pre><code class="language-ts">// app/seeders/default_seeder.ts
import { BaseSeeder } from '@adonisjs/lucid/seeders'
++import Role from '#models/role'

export default class extends BaseSeeder {
  async run() {
    // Write your database queries inside the run method
++    await Role.createMany([{ name: 'User' }, { name: 'Admin' }])
  }
}</code></pre><p>In the vast majority of cases, you'll want to let your database set your records' IDs. However, for our roles, we have 1 hard-coded as the default <code>role_id</code> in our <code>create_users_table</code> migration. So, it would be great to force our role's ID in our seeder so we know for a fact we have a user role with an ID of 1 and an admin role with an ID of 2. We don't want to allow an ID of 1 to have a possibility of becoming a super user or anything of the sort.</p><pre><code class="language-ts">// app/seeders/default_seeder.ts
import Role from '#models/role'
import { BaseSeeder } from '@adonisjs/lucid/seeders'

export default class extends BaseSeeder {
  async run() {
    // Write your database queries inside the run method
++    await Role.createMany([
++      { id: 1, name: 'User' },
++      { id: 2, name: 'Admin' },
++    ])
  }
}</code></pre><p>We can take this a step further and give ourselves an enum-like const representation of these values to use throughout our application as well.</p><pre><code class="language-bash">touch app/enums/roles.ts</code></pre><pre><code class="language-ts">// app/enums/roles.ts
export const Roles = {
  USER: 1,
  ADMIN: 2,
} as const</code></pre><p>Then, we can use that in place of hard-coded numbers so they're easier to read!</p><pre><code class="language-ts">// app/seeders/default_seeder.ts
import Role from '#models/role'
import { BaseSeeder } from '@adonisjs/lucid/seeders'
import { Roles } from '../../app/enums/roles.ts'

export default class extends BaseSeeder {
  async run() {
    // Write your database queries inside the run method
    await Role.createMany([
++      { id: Roles.USER, name: 'User' },
++      { id: Roles.ADMIN, name: 'Admin' },
    ])
  }
}</code></pre><p>Note that the import here is a relative import. If you prefer a subpath import, as our models use, we can add one to our <code>package.json</code> for our new <code>enums</code> directory.</p><pre><code class="language-json">// package.json
{
  ".": "...",
  "imports": {
    "#controllers/*": "./app/controllers/*.js",
++    "#enums/*": "./app/enums/*.js",
    "#exceptions/*": "./app/exceptions/*.js",
    "#models/*": "./app/models/*.js",
    "#mails/*": "./app/mails/*.js",
    "#services/*": "./app/services/*.js",
    "#listeners/*": "./app/listeners/*.js",
    "#generated/*": "./.adonisjs/server/*.js",
    "#events/*": "./app/events/*.js",
    "#middleware/*": "./app/middleware/*.js",
    "#validators/*": "./app/validators/*.js",
    "#providers/*": "./providers/*.js",
    "#policies/*": "./app/policies/*.js",
    "#abilities/*": "./app/abilities/*.js",
    "#database/*": "./database/*.js",
    "#tests/*": "./tests/*.js",
    "#start/*": "./start/*.js",
    "#config/*": "./config/*.js"
  },
  ".": "...",
}</code></pre><p>We can then switch our role import to be from <code>#enums/roles</code>.</p><pre><code class="language-ts">// app/seeders/default_seeder.ts
import Role from '#models/role'
import { BaseSeeder } from '@adonisjs/lucid/seeders'
++import { Roles } from '#enums/roles'

export default class extends BaseSeeder {
  async run() {
    // Write your database queries inside the run method
    await Role.createMany([
++      { id: Roles.USER, name: 'User' },
++      { id: Roles.ADMIN, name: 'Admin' },
    ])
  }
}</code></pre><p>Again, we can also update our migration; the value will remain the same, so no need to refresh or re-run the migration for this change.</p><pre><code class="language-ts">// database/migrations/xxxxx_create_users_table.ts
import { BaseSchema } from '@adonisjs/lucid/schema'
++import { Roles } from '#enums/roles'

export default class extends BaseSchema {
  protected tableName = 'users'

  async up() {
    this.schema.createTable(this.tableName, (table) =&gt; {
      table.increments('id').notNullable()
      table
        .integer('role_id')
        .unsigned()
        .references('id')
        .inTable('roles')
        .notNullable()
++        .defaultTo(Roles.USER)
      table.string('full_name').nullable()
      table.string('email', 254).notNullable().unique()
      table.string('password').notNullable()

      table.timestamp('created_at').notNullable()
      table.timestamp('updated_at').nullable()
    })
  }

  async down() {
    this.schema.dropTable(this.tableName)
  }
}</code></pre><h4>Running Seeders</h4><p>We can run seeders using the <code>db:seed</code> Ace CLI command. This will run all seeders defined in our application, though we can specify a specific file or files using the <code>--files</code> option or its alias <code>-f</code>. There's also an interactive mode we can use with <code>--interactive</code> or its alias <code>-i</code>. The interactive mode will allow us to select specifically which seeders we'd like to run from a list of our seeders.</p><pre><code class="language-bash">node ace db:seed</code></pre><p>However, if we were to try to run our seeder at the moment, we would get a unique constraint.</p><pre><code class="language-bash">node ace db:seed
#❯ error     database/seeders/default_seeder
#  insert into `roles` (`created_at`, `id`, `name`, `updated_at`) values ('2026-02-06 01:00:50', 1, 'User', '2026-02-06 01:00:50') - UNIQUE constraint failed: roles.id</code></pre><p>Remember, we've already created a role with an ID of 1 in the last lesson using REPL, so the unique constraint is complaining that the ID of 1 we're trying to insert already exists. So, let's wipe our database's data first!</p><pre><code class="language-bash">node ace migration:fresh</code></pre><p>This will drop all our tables and rerun all our migrations. Then, we can retry our seeder.</p><pre><code class="language-bash">node ace db:seed
# "sqlite" Role (2.27 ms) INSERT INTO `roles` (`created_at`, `id`, `name`, `updated_at`) VALUES (?, ?, ?, ?) [ '2026-02-06 01:07:31', 1, 'User', '2026-02-06 01:07:31' ]
# "sqlite" Role (56 μs) INSERT INTO `roles` (`created_at`, `id`, `name`, `updated_at`) VALUES (?, ?, ?, ?) [ '2026-02-06 01:07:31', 2, 'Admin', '2026-02-06 01:07:31' ]
# ❯ completed database/seeders/default_seeder</code></pre><p>By the way, we could've done all of that in a single command with:</p><pre><code class="language-bash">node ace migration:fresh --seed
# or
node ace migration:refresh --seed</code></pre><p>This would've run <code>migration:fresh</code> or <code>migration:refresh</code> and all our seeders in a single command.</p><h4>Environment Specific Seeders</h4><p>Our default seeder, creating our roles, we want to run in any environment because every environment needs our roles to exist. However, if we're creating dummy data for our development environment, we don't want to run the risk of accidentally running that in production. To avoid that, we can gate it to specific environments.</p><p>To start, let's make a dev seeder.</p><pre><code class="language-bash">node ace make:seeder dev</code></pre><p>Then, to gate it so it will only run in development, we can add a static <code>environment</code> property on the class. This accepts a string array of the environments we want this seeder to be allowed to run within. So, by adding "development" to this array, we're limiting the seeder so it can only run in our development environment.</p><pre><code class="language-ts">// database/seeders/dev_seeder.ts
import { BaseSeeder } from '@adonisjs/lucid/seeders'

export default class extends BaseSeeder {
++  static environment: string[] = ['development']

  async run() {
    // Write your database queries inside the run method
  }
}</code></pre><p>Now, we don't want to have to create a bunch of dummy data for development by hand, as we did with our roles. To fix that, we can use factories.</p><h2>Factories</h2><p>Factories are wrappers around our models to aid in creating real database records with fake data. This can be handled in two scenarios.</p><ol><li><p>Quickly seeding development data without manual data entry.</p></li><li><p>Aide with testing, allowing us to create data with breeze on a per-test basis.</p></li></ol><p>We can easily create factories using the Ace CLI's <code>make:factory</code> command, or by providing the <code>-f</code> flag when using the <code>make:model</code> command. We've already created our models, so let's use <code>make:factory</code>, starting with our challenge.</p><pre><code class="language-bash">node ace make:factory challenge
# DONE:    create database/factories/challenge_factory.ts</code></pre><p>We can find our factories within the <code>database/factories/</code> directory, and our new <code>RoleFactory</code> will look like below.</p><pre><code class="language-ts">// database/factories/challenge_factory.ts
import factory from '@adonisjs/lucid/factories'
import Challenge from '#models/challenge'

export const ChallengeFactory = factory
  .define(Challenge, async ({ faker }) =&gt; {
    return {}
  })
  .build()</code></pre><p>Factories are defined by passing it our model's class, <code>Challenge</code>, as the first argument. Then, in the second argument, we use a callback function to return a representation of how that model's data should be filled with fake data. To do this, we'll use <code>faker</code> which is being destructured from the <code>FactoryContext</code>.</p><p>Despite our <code>challenges</code> table containing the following columns:</p><ul><li><p><code>id</code></p></li><li><p><code>creator_id</code></p></li><li><p><code>text</code></p></li><li><p><code>points</code></p></li><li><p><code>created_at</code></p></li><li><p><code>updated_at</code></p></li></ul><p>We only need to worry about specifying our our <code>text</code> and <code>points</code> columns should be populated with fake data. Our database will automatically handle <code>id</code>. AdonisJS will automatically handle our <code>created_at</code> and <code>updated_at</code> thanks to the options provided to the <code>@column.dateTime()</code> decorator on the column's model representation. Then, <code>creator_id</code> relates a challenge to a user and has a foreign key constraint. So, we'd be better off deferring that to a relationship, as we'll see in a bit.</p><h4>Defining Factory Properties</h4><p>So, to start, we can add a <code>text</code> property to the object being returned by our factory's callback method. Then, we can use <code>faker</code> to describe what sort of fake value should be used to populate <code>text</code>. Using the model passed into the first argument, Lucid will also provide us with type safety here and autocomplete for our model's properties.</p><pre><code class="language-ts">// database/factories/challenge_factory.ts
import Challenge from '#models/challenge'
import factory from '@adonisjs/lucid/factories'

export const ChallengeFactory = factory
  .define(Challenge, async ({ faker }) =&gt; {
    return {
++      text: faker.git.commitMessage(),
    }
  })
  .build()</code></pre><p>The <code>faker</code> object contains several category-based properties, and each category contains another object with methods inside it. We'll call one of those methods to describe the type of value that should be used for our property. Within <code>faker</code> there isn't a great option that suits a "challenge", but we can use a like-value we should be familiar with by utilizing <code>faker.git.commitMessage()</code> to assign our <code>text</code> a fake commit message, like "reboot cross-platform driver."</p><p>Next, we can do the same for our points, describing it should be an integer number. Some of these faker methods accept options, like the <code>int()</code> method. We can use it to keep the fake value within a min and max range matching that of our validation, 1 and 100.</p><pre><code class="language-ts">// database/factories/challenge_factory.ts
import Challenge from '#models/challenge'
import factory from '@adonisjs/lucid/factories'

export const ChallengeFactory = factory
  .define(Challenge, async ({ faker }) =&gt; {
    return {
      text: faker.git.commitMessage(),
++      points: faker.number.int({ min: 1, max: 100 }),
    }
  })
  .build()</code></pre><p>Fantastic! Before we can add relationships to our factory, we must first have a factory representation of the related model. So, let's go ahead and create our other two factories. Again, similar to our model the <code>challenge_user</code> table is a pivot table and can be sufficiently represented by a relationship between our <code>Challenge</code> and <code>User</code> model. Then, we won't be making a factory for our <code>Role</code> model because those are usually static within an application and won't need to use fake data.</p><pre><code class="language-bash">node ace make:factory profile
# DONE:    create database/factories/profile_factory.ts</code></pre><pre><code class="language-bash">node ace make:factory user
# DONE:    create database/factories/user_factory.ts</code></pre><p>For our <code>ProfileFactory</code>, we just need to specify how to populate our <code>bio</code> and <code>is_public</code> columns. Within the <code>person</code> category there is a one-to-one match for our <code>bio</code>. Then, in <code>datatype</code> there is a one-to-one match for a <code>boolean</code> value for our <code>is_public</code> flag.</p><pre><code class="language-ts">// database/factories/profile_factory.ts
import Profile from '#models/profile'
import factory from '@adonisjs/lucid/factories'

export const ProfileFactory = factory
  .define(Profile, async ({ faker }) =&gt; {
    return {
      bio: faker.person.bio(),
      isPublic: faker.datatype.boolean(),
    }
  })
  .build()</code></pre><p>Finally, we have our <code>UserFactory</code>. Like we did in our migration, we can hard-code the <code>roleId</code> to just 1 which will represent our basic "user" role. Then we have one-to-one matches for our <code>fullName</code> and <code>email</code> columns. For our password, we could use <code>faker.internet.password()</code>. However, our <code>User</code> model will hash the password before saving it into the database. Meaning, if we wanted to login as any of these fake users, we'd need to reset their password. Since we're in development and using a database on our local machine, we can save ourselves the hassle and hard-code the password to something so we know everyone's password on our local database.</p><pre><code class="language-ts">// database/factories/user_factory.ts
import User from '#models/user'
import factory from '@adonisjs/lucid/factories'

export const UserFactory = factory
  .define(User, async ({ faker }) =&gt; {
    return {
      roleId: 1,
      fullName: faker.person.fullName(),
      email: faker.internet.email(),
      password: 'something',
    }
  })
  .build()</code></pre><h4>Factory Relationships</h4><p>By adding relationships to our factories, we're giving ourselves the ability to create a fake challenge with a fake creator and a bunch of fake participants without having to specify any data. We can easily define a factory relationship using the <code>relation()</code> method. We'll pass the property name of the relationship as the first argument and give a callback function returning that relationship's factory as the second.</p><pre><code class="language-ts">// database/factories/user_factory.ts
import User from '#models/user'
import factory from '@adonisjs/lucid/factories'
import { ChallengeFactory } from './challenge_factory.ts'
import { ProfileFactory } from './profile_factory.ts'

export const UserFactory = factory
  .define(User, async ({ faker }) =&gt; {
    return {
      roleId: 1,
      fullName: faker.person.fullName(),
      email: faker.internet.email(),
      password: faker.internet.password(),
    }
  })
  .relation('challenges', () =&gt; ChallengeFactory)
  .relation('createdChallenges', () =&gt; ChallengeFactory)
  .relation('profile', () =&gt; ProfileFactory)
  .build()</code></pre><p>Let's go ahead and fill in the profile's relationship to the user.</p><pre><code class="language-ts">// database/factories/profile_factory.ts
import Profile from '#models/profile'
import factory from '@adonisjs/lucid/factories'
import { UserFactory } from './user_factory.ts'

export const ProfileFactory = factory
  .define(Profile, async ({ faker }) =&gt; {
    return {
      bio: faker.person.bio(),
      isPublic: faker.datatype.boolean(),
    }
  })
  .relation('user', () =&gt; UserFactory)
  .build()</code></pre><p>Finally, our challenge's relationship mirrors our user.</p><pre><code class="language-ts">// database/factories/challenge_factory.ts
import Challenge from '#models/challenge'
import factory from '@adonisjs/lucid/factories'
import { UserFactory } from './user_factory.ts'

export const ChallengeFactory = factory
  .define(Challenge, async ({ faker }) =&gt; {
    return {
      text: faker.git.commitMessage(),
      points: faker.number.int({ min: 1, max: 100 }),
    }
  })
  .relation('createdBy', () =&gt; UserFactory)
  .relation('participants', () =&gt; UserFactory)
  .build()</code></pre><h4>Using Factories</h4><p>Now that we have our factories filled out, we can use them to create dummy data for our development environment using our <code>DevSeeder</code>. Like our model, factories also have a <code>create()</code> method. However, since the instance is filled with our faker dummy data, we don't need to specify any data for it!</p><pre><code class="language-ts">// database/seeders/dev_seeder.ts
import { ChallengeFactory } from '#database/factories/challenge_factory'
import { BaseSeeder } from '@adonisjs/lucid/seeders'

export default class extends BaseSeeder {
  static environment: string[] = ['development']

  async run() {
    // Write your database queries inside the run method
    await ChallengeFactory.create()
  }
}</code></pre><p>One challenge isn't much for dummy data, though. Let's create a lot using <code>createMany()</code> instead! All we need to do is tell it how many challenges to create, and it'll do the rest. Let's make 50!</p><pre><code class="language-ts">// database/seeders/dev_seeder.ts
import { ChallengeFactory } from '#database/factories/challenge_factory'
import { BaseSeeder } from '@adonisjs/lucid/seeders'

export default class extends BaseSeeder {
  static environment: string[] = ['development']

  async run() {
    // Write your database queries inside the run method
    await ChallengeFactory.createMany(50)
  }
}</code></pre><p>Our challenges also need a creator, that's a not-nullable property. So, to include a creator with our challenge, we can use its <code>createdBy</code> relationship to include a fake user with each fake factory.</p><pre><code class="language-ts">// database/seeders/dev_seeder.ts
import { ChallengeFactory } from '#database/factories/challenge_factory'
import { BaseSeeder } from '@adonisjs/lucid/seeders'

export default class extends BaseSeeder {
  static environment: string[] = ['development']

  async run() {
    // Write your database queries inside the run method
    await ChallengeFactory.with('createdBy', 50).createMany(50)
  }
}</code></pre><p>Our creator could also use a profile, though. To include a nested relationship, we can include a third argument to get access to our <code>UserFactory</code> builder to include a profile for the user.</p><pre><code class="language-ts">// database/seeders/dev_seeder.ts
import { ChallengeFactory } from '#database/factories/challenge_factory'
import { BaseSeeder } from '@adonisjs/lucid/seeders'

export default class extends BaseSeeder {
  static environment: string[] = ['development']

  async run() {
    // Write your database queries inside the run method
    await ChallengeFactory
      .with('createdBy', 50, (builder) =&gt; builder.with('profile'))
      .createMany(50)
  }
}</code></pre><p>This will create 50 challenges with 50 users, one creator per challenge. What if we instead wanted a pool of 10 creators to randomly spread across our 50 challenges? Well, we could start by first creating our users.</p><pre><code class="language-ts">// database/seeders/dev_seeder.ts
import { ChallengeFactory } from '#database/factories/challenge_factory'
import { UserFactory } from '#database/factories/user_factory'
import { BaseSeeder } from '@adonisjs/lucid/seeders'

export default class extends BaseSeeder {
  static environment: string[] = ['development']

  async run() {
    // create 10 users and grab their ids
    const creators = await UserFactory.with('profile').createMany(10)
    const creatorIds = creators.map((user) =&gt; user.id)

    await ChallengeFactory.createMany(50)
  }
}</code></pre><p>Then, we can <code>tap()</code> into each challenge prior to it being persisted to randomly set which of our <code>creatorIds</code> get set as that challenge's <code>creatorId</code>.</p><pre><code class="language-ts">// database/seeders/dev_seeder.ts
import { ChallengeFactory } from '#database/factories/challenge_factory'
import { UserFactory } from '#database/factories/user_factory'
import { BaseSeeder } from '@adonisjs/lucid/seeders'

export default class extends BaseSeeder {
  static environment: string[] = ['development']

  async run() {
    // create 10 users and grab their ids
    const creators = await UserFactory.with('profile').createMany(10)
    const creatorIds = creators.map((user) =&gt; user.id)

    // create 50 challenges with a random creator
    await ChallengeFactory.tap((challenge, { faker }) =&gt; {
      challenge.creatorId = faker.helpers.arrayElement(creatorIds)
    }).createMany(50)
  }
}</code></pre><p>The <code>tap()</code> method is run once for each challenge being created. It receives an individual challenge model instance as its first parameter and the <code>FactoryContext</code> as its second parameter. So, we have <code>faker</code> at our disposal and <code>faker</code> contains a helper, <code>arrayElement</code>, that returns a random item from the provided array. In turn, this gets us a random user from our pool of created users.</p><p>Finally, we could use some participants. However, to do those from a pool of users as we did with our creators, we would need to be familiar with creating Lucid relationships. Let's not jump ahead and instead just create 6 participants per challenge.</p><pre><code class="language-ts">import { ChallengeFactory } from '#database/factories/challenge_factory'
import { UserFactory } from '#database/factories/user_factory'
import { BaseSeeder } from '@adonisjs/lucid/seeders'

export default class extends BaseSeeder {
  static environment: string[] = ['development']

  async run() {
    // create 10 users and grab their ids
    const creators = await UserFactory.with('profile').createMany(10)
    const creatorIds = creators.map((user) =&gt; user.id)

    // create 50 challenges with a random creator
    await ChallengeFactory.tap((challenge, { faker }) =&gt; {
      challenge.creatorId = faker.helpers.arrayElement(creatorIds)
    })
      .with('participants', 6, (builder) =&gt; builder.with('profile'))
      .createMany(50)
  }
}</code></pre><p>We've already run our <code>DefaultSeeder</code>, so specify that we specifically want to run our <code>DevSeeder</code> via the <code>db:seed</code> Ace CLI command.</p><pre><code class="language-bash">node ace db:seed --files=database/seeders/dev_seeder  </code></pre><p>A couple of things to note:</p><ol><li><p>The <code>--files</code> option should point to the seeder file from the application root</p></li><li><p>Factories are created in a transaction, and we're creating many records with individual operations, so this may take a second</p></li></ol><p>With that, if we boot our application back up with <code>npm run dev</code> and head back into our <code>/challenges</code> page, we should see 50 challenges, each with varying point values!</p><p>If you're interested in learning more about using factories, especially in tests, check out our <a target="_blank" rel="nofollow noopener noreferrer" href="https://adocasts.com/series/pragmatic-testing-in-adonisjs-with-japa">series on testing with Japa</a>!</p>]]></content:encoded>
      </item>
      <item>
        <title><![CDATA[CRUD Basics]]></title>
        <link>https://adocasts.com/lessons/crud-basics</link>
        <guid>https://adocasts.com/lessons/crud-basics</guid>
        <pubDate>Fri, 08 May 2026 11:00:00 +0000</pubDate>
        <description><![CDATA[Learn CRUD operations in AdonisJS. Create, read, update, and delete records using AdonisJS models and Lucid ORM.]]></description>
        <content:encoded><![CDATA[<p>Now that we have our models defined, we can use them throughout our application to perform create, read, update, and delete (CRUD) operations against our database.</p><p>If you'll recall, we were mimicking create, read, and update operations within our <code>ChallengesController</code> using an in-memory array. Let's work on transitioning that to use our database instead.</p><h2>Creating Records</h2><p>At the moment, we're just pushing a new challenge into an array defined above our controller's class.</p><pre><code class="language-ts">// app/controllers/challenges_controller.ts
const challenges = [
  { id: 1, text: 'Learn AdonisJS', points: 10 },
  { id: 2, text: 'Learn EdgeJS', points: 5 },
  { id: 3, text: 'Build an AdonisJS app', points: 20 },
]

export default class ChallengesController {
  // ...

  /**
   * Handle form submission for the create action
   */
  async store({ request, response, session }: HttpContext) {
    const data = await challengeValidator.validate(request.all())

    challenges.push({ id: challenges.length + 1, ...data })

    session.flash('success', 'Challenge created successfully')

    return response.redirect().toRoute('challenges.index')
  }

  // ...
}</code></pre><p>What we want to do instead, to use our database, is import our <code>Challenge</code> model. If you'll recall from our last lesson, a model instance represents a single row within our table. So, by instantiating an instance of our <code>Challenge</code> model we're creating a representation of a new row in our <code>challenges</code> table.</p><pre><code class="language-ts">// app/controllers/challenges_controller.ts
import Challenge from '#models/challenge'

const challenges = [
  { id: 1, text: 'Learn AdonisJS', points: 10 },
  { id: 2, text: 'Learn EdgeJS', points: 5 },
  { id: 3, text: 'Build an AdonisJS app', points: 20 },
]

export default class ChallengesController {
  // ...

  /**
   * Handle form submission for the create action
   */
  async store({ request, response, session }: HttpContext) {
    const data = await challengeValidator.validate(request.all())

++    const challenge = new Challenge()

    session.flash('success', 'Challenge created successfully')

    return response.redirect().toRoute('challenges.index')
  }

  // ...
}</code></pre><p>However, just instantiating a new instance won't automatically populate it with our data, nor will it create or persist the row inside our table. We need to do those as well, and again, we have a couple of options. First, we can be verbose and manually populate each column/property of our new model instance.</p><pre><code class="language-ts">// app/controllers/challenges_controller.ts
import Challenge from '#models/challenge'

const challenges = [
  { id: 1, text: 'Learn AdonisJS', points: 10 },
  { id: 2, text: 'Learn EdgeJS', points: 5 },
  { id: 3, text: 'Build an AdonisJS app', points: 20 },
]

export default class ChallengesController {
  // ...

  /**
   * Handle form submission for the create action
   */
  async store({ request, response, session }: HttpContext) {
    const data = await challengeValidator.validate(request.all())

    const challenge = new Challenge()

++    challenge.text = data.text
++    challenge.points = data.points

++    await challenge.save()

    session.flash('success', 'Challenge created successfully')

    return response.redirect().toRoute('challenges.index')
  }

  // ...
}</code></pre><p>When we call <code>await challenge.save()</code> that is where the current values are held on our <code>challenge</code> instance are persisted into the database. An alternative to explicitly populating each column in our model is to merge them. This pairs fantastically with validated data that matches the structure of our model, as we have here, because it results in super clean code!</p><pre><code class="language-ts">// app/controllers/challenges_controller.ts
import Challenge from '#models/challenge'

const challenges = [
  { id: 1, text: 'Learn AdonisJS', points: 10 },
  { id: 2, text: 'Learn EdgeJS', points: 5 },
  { id: 3, text: 'Build an AdonisJS app', points: 20 },
]

export default class ChallengesController {
  // ...

  /**
   * Handle form submission for the create action
   */
  async store({ request, response, session }: HttpContext) {
    const data = await challengeValidator.validate(request.all())

    const challenge = new Challenge()

++    challenge.merge(data)

    await challenge.save()

    session.flash('success', 'Challenge created successfully')

    return response.redirect().toRoute('challenges.index')
  }

  // ...
}</code></pre><p>Might not be a huge difference with our small model, but it comes in very handy with larger models! Even better yet, we can condense the entire creation to a single line using our model's static <code>create</code> method. The <code>create</code> method essentially does exactly what we previously had, just in a smaller package, allowing us to create a challenge with just:</p><pre><code class="language-ts">// app/controllers/challenges_controller.ts
import Challenge from '#models/challenge'

const challenges = [
  { id: 1, text: 'Learn AdonisJS', points: 10 },
  { id: 2, text: 'Learn EdgeJS', points: 5 },
  { id: 3, text: 'Build an AdonisJS app', points: 20 },
]

export default class ChallengesController {
  // ...

  /**
   * Handle form submission for the create action
   */
  async store({ request, response, session }: HttpContext) {
    const data = await challengeValidator.validate(request.all())

++    const challenge = await Challenge.create(data)

    session.flash('success', 'Challenge created successfully')

    return response.redirect().toRoute('challenges.index')
  }

  // ...
}</code></pre><p>Though this does return the newly created model instance representing the row we've just persisted into the database, we don't need to make use of that at all here, so we can just omit that so we aren't leaving an unused variable.</p><pre><code class="language-ts">// app/controllers/challenges_controller.ts
import Challenge from '#models/challenge'

const challenges = [
  { id: 1, text: 'Learn AdonisJS', points: 10 },
  { id: 2, text: 'Learn EdgeJS', points: 5 },
  { id: 3, text: 'Build an AdonisJS app', points: 20 },
]

export default class ChallengesController {
  // ...

  /**
   * Handle form submission for the create action
   */
  async store({ request, response, session }: HttpContext) {
    const data = await challengeValidator.validate(request.all())

++    await Challenge.create(data)

    session.flash('success', 'Challenge created successfully')

    return response.redirect().toRoute('challenges.index')
  }

  // ...
}</code></pre><p>Note, there's also a <code>createMany()</code> method that allows us to create many records at once by providing an array of the data we want to create. This will also return an array of the created instances.</p><pre><code class="language-ts">const challenges = await Challenge.createMany([
  challengeDataOne,
  challengeDataTwo
])</code></pre><h4>An Aside About Foreign Keys &amp; REPL</h4><p>One issue, though, our <code>challenges</code> table has a not null <code>creator_id</code> column within it. So, if we were to attempt to create a challenge, we'll get:</p><pre><code> insert into `challenges` (`created_at`, `points`, `text`, `updated_at`) values ('2026-02-05 00:12:16', 20, 'testing', '2026-02-05 00:12:16') - NOT NULL constraint failed: challenges.creator_id</code></pre><p>We also can't just plop any ol' number in as a <code>creator_id</code> because we have a foreign key constraint on this column requiring it to hold a value existing within our <code>users.id</code>.</p><pre><code class="language-ts">async store({ request, response, session }: HttpContext) {
  const data = await challengeValidator.validate(request.all())

++  await Challenge.create({ creatorId: 1, ...data })

  session.flash('success', 'Challenge created successfully')

  return response.redirect().toRoute('challenges.index')
}</code></pre><p>If we were to try, we'd get:</p><pre><code>insert into `challenges` (`created_at`, `creator_id`, `points`, `text`, `updated_at`) values ('2026-02-05 00:18:53', 1, 50, 'testing', '2026-02-05 00:18:53') - FOREIGN KEY constraint failed</code></pre><p>Thankfully, the AdonisJS starter kit came with working authentication, so we could solve this by quickly registering a user. However, our user will have the same problem because it requires a <code>role_id</code>, but our <code>roles</code> table is empty at the moment.</p><p>In the next lesson, we'll see how we can remedy this situation with seeders. For now, we can actually jump into a Read-Evaluate Print Loop (REPL) session to quickly create a role. A REPL session is like a scratch pad version of our environment. To enter it, head into your terminal and run <code>node ace repl</code>.</p><pre><code class="language-bash">node ace repl

# Type ".ls" to a view list of available context methods/properties
# &gt; (js) </code></pre><p>If you type <code>.ls</code> and hit enter, you'll see a list of all available methods &amp; properties.</p><pre><code class="language-bash">&gt; (js) .ls

GLOBAL METHODS:
importDefault         Returns the default export for a module
importAll             Import all files from a directory and assign them to a variable
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
loadUrlBuilder        Load "urlBuilder" service in the REPL context
loadModels            Recursively load Lucid models to the "models" property
loadDb                Load database provider to the "db" property
loadFactories         Recursively load factories to the "factories" property
clear (propertyName)  Clear a property from the REPL context
p (function)          Promisify a function. Similar to Node.js "util.promisify"

CONTEXT PROPERTIES/METHODS:
{}</code></pre><p>What we're after is the <code>loadModels()</code> method. This will make our models available via a <code>models</code> property.</p><pre><code class="language-bash">await loadModels()
# recursively reading models from "app/models"
# Loaded models module. You can access it using the "models" variable
# &gt; (js) undefined</code></pre><p>This will execute the method and show us what it returned, which was <code>undefined</code>. We can then use the loaded models to create a new role, same as we did in our controller.</p><pre><code class="language-bash">await models.role.create({ name: 'User' })
# "sqlite" Role (2 ms) INSERT INTO `roles` (`created_at`, `name`, `updated_at`) VALUES (?, ?, ?) [ '2026-02-05 00:34:55', 'User', '2026-02-05 00:34:55' ]
# Role {
#   modelOptions: undefined,
#   modelTrx: undefined,
#   transactionListener: [Function: bound listener],
#   fillInvoked: false,
#   cachedGetters: {},
#   forceUpdate: false,
#   '$columns': [ 'id', 'name', 'createdAt', 'updatedAt' ],
#   '$attributes': {
#     name: 'User',
#     createdAt: DateTime { ts: 2026-02-05T00:34:55.652+00:00, zone: UTC, locale: en-US },
#     updatedAt: DateTime { ts: 2026-02-05T00:34:55.652+00:00, zone: UTC, locale: en-US },
#     id: 1
#   },
#   '$original': {
#     name: 'User',
#     createdAt: DateTime { ts: 2026-02-05T00:34:55.652+00:00, zone: UTC, locale: en-US },
#     updatedAt: DateTime { ts: 2026-02-05T00:34:55.652+00:00, zone: UTC, locale: en-US },
#     id: 1
#   },
#   '$preloaded': {},
#   '$extras': {},
#   '$sideloaded': {},
#   '$isPersisted': true,
#   '$isDeleted': false,
#   '$isLocal': true,
#   name: 'User',
#   createdAt: DateTime { ts: 2026-02-05T00:34:55.652+00:00, zone: UTC, locale: en-US },
#   updatedAt: DateTime { ts: 2026-02-05T00:34:55.652+00:00, zone: UTC, locale: en-US }
# }</code></pre><p>As you can see, our <code>create()</code> method returned the newly created instance of our role model! Since it's the first role that's been created in the table, it was given the ID of 1. Let's go ahead and do the same for our user while we're here. Alternatively, you could use the signup form on our site.</p><pre><code class="language-bash">&gt; (js) await models.user.create({
|   fullName: 'Tom Gobich',
|   email: 'tom@adocasts.com',
  | password: 'something'
| })
# "sqlite" User (1.85 ms) INSERT INTO `users` (`created_at`, `email`, `full_name`, `password`, `updated_at`) VALUES (?, ?, ?, ?, ?) [
#   '2026-02-05 00:41:22',
#   'tom@adocasts.com',
#   'Tom Gobich',
#   '$scrypt$n=16384,r=8,p=1$fLEoMihP9M2nDqxhtGFWCA$pMgwjKJE1WhRMcAgLvEMCha54J7rcVbapRQF7QSpgMxn1j6WUuW7CIdK6r7yYISJojEdhbMvWnqelb6ujGcU5Q',
#   '2026-02-05 00:41:22'
# ]
# User {
#   modelOptions: undefined,
#   modelTrx: undefined,
#   transactionListener: [Function: bound listener],
#   fillInvoked: false,
#   cachedGetters: {},
#   forceUpdate: false,
#   '$columns': [
#     'id',
#     'roleId',
#     'fullName',
#     'email',
#     'password',
#     'createdAt',
#     'updatedAt'
#   ],
#   '$attributes': {
#     fullName: 'Tom Gobich',
#     email: 'tom@adocasts.com',
#     password: '$scrypt$n=16384,r=8,p=1$fLEoMihP9M2nDqxhtGFWCA$pMgwjKJE1WhRMcAgLvEMCha54J7rcVbapRQF7QSpgMxn1j6WUuW7CIdK6r7yYISJojEdhbMvWnqelb6ujGcU5Q',
#     createdAt: DateTime { ts: 2026-02-05T00:41:22.828+00:00, zone: UTC, locale: en-US },
#     updatedAt: DateTime { ts: 2026-02-05T00:41:22.828+00:00, zone: UTC, locale: en-US },
#     id: 1
#   },
#   '$original': {
#     fullName: 'Tom Gobich',
#     email: 'tom@adocasts.com',
#     password: '$scrypt$n=16384,r=8,p=1$fLEoMihP9M2nDqxhtGFWCA$pMgwjKJE1WhRMcAgLvEMCha54J7rcVbapRQF7QSpgMxn1j6WUuW7CIdK6r7yYISJojEdhbMvWnqelb6ujGcU5Q',
#     createdAt: DateTime { ts: 2026-02-05T00:41:22.828+00:00, zone: UTC, locale: en-US },
#     updatedAt: DateTime { ts: 2026-02-05T00:41:22.828+00:00, zone: UTC, locale: en-US },
#     id: 1
#   },
#   '$preloaded': {},
#   '$extras': {},
#   '$sideloaded': {},
#   '$isPersisted': true,
#   '$isDeleted': false,
#   '$isLocal': true,
#   fullName: 'Tom Gobich',
#   email: 'tom@adocasts.com',
#   password: '$scrypt$n=16384,r=8,p=1$fLEoMihP9M2nDqxhtGFWCA$pMgwjKJE1WhRMcAgLvEMCha54J7rcVbapRQF7QSpgMxn1j6WUuW7CIdK6r7yYISJojEdhbMvWnqelb6ujGcU5Q',
#   createdAt: DateTime { ts: 2026-02-05T00:41:22.828+00:00, zone: UTC, locale: en-US },
#   updatedAt: DateTime { ts: 2026-02-05T00:41:22.828+00:00, zone: UTC, locale: en-US }
# }</code></pre><p>A few things to note here.</p><ol><li><p>REPL is smart enough to know not to execute when line breaks are entered with unclosed blocks, allowing new lines as we enter an object's details.</p></li><li><p>Our password was entered as <code>something</code>, but was persisted into the database as <code>$scrypt$n=163...</code>. This is done automatically for us via an <code>@beforeSave()</code> hook added onto our <code>User</code> model by AdonisJS's authentication. We'll discuss this more later in the series.</p></li></ol><p>Fantastic! We've now got our role and user, so we can exit our REPL session by typing <code>.exit</code> and hitting enter. Let's then boot our server back up and try creating a challenge once more.</p><p>If all went according to plan, we should see our "Challenge created successfully" toast message and be redirected back to <code>challenges.index</code>. However, we're still reading from our in-memory array so we don't see it within our list. Let's fix that next.</p><h2>Reading Data</h2><p>Our models provide a number of different ways to query data from our database because getting data is never a one-size-fits-all problem. In this lesson, we'll only be looking at the static method options available for querying data. These static methods are for simple and common use-cases, but do note that our models also have a query builder on them to allow complex queries. We'll introduce that a little later on.</p><p>Starting simple, we can grab everything in our <code>challenges</code> table using the static <code>all()</code> method. I'll call our variable <code>list</code> here because <code>challenges</code> is still in use.</p><pre><code class="language-ts">// app/controllers/challenges_controller.ts
const challenges = [{ ... }]

export default class ChallengesController {
  /**
   * Display a list of resource
   */
  async index({ view }: HttpContext) {
++    const list = await Challenge.all()
++    return view.render('pages/challenges/index', { challenges: list })
  }

  // ...
}</code></pre><p>If you refresh your page, you should now see the challenge or challenges you've persisted thus far into your database. Anything more complex than this, and you'd want to reach for the query builder, again, more on that later.</p><p>When trying to get at a single record, we have a few static options. First, we can <code>find()</code> a row by providing it's id as the argument.</p><pre><code class="language-ts">// app/controllers/challenges_controller.ts
const challenges = [{ ... }]

export default class ChallengesController {
  // ...

  /**
   * Show individual record
   */
  async show({ view, params }: HttpContext) {
++    const challenge = await Challenge.find(params.id)
    const editUrl = urlFor('challenges.edit', { id: params.id })

    return view.render('pages/challenges/show', { challenge, editUrl })
  }

  // ...
}</code></pre><p>This will attempt to find a challenge with the provided ID and return <code>null</code> if one cannot be found. We can also <code>findBy()</code>, providing a column we want to search against in the first argument and the value in the second.</p><pre><code class="language-ts">// app/controllers/challenges_controller.ts
const challenges = [{ ... }]

export default class ChallengesController {
  // ...

  /**
   * Show individual record
   */
  async show({ view, params }: HttpContext) {
++    const challenge = await Challenge.findBy('id', params.id)
    const editUrl = urlFor('challenges.edit', { id: params.id })

    return view.render('pages/challenges/show', { challenge, editUrl })
  }

  // ...
}</code></pre><p>Note, this can also be written as <code>await Challenge.findBy({ id: params.id })</code>. Both of these also have many variants, allowing you to provide more than one value as an array. These will return an array of all records that've matched your search. For example,</p><pre><code class="language-ts">const challenges = await Challenge.findMany([1, 2, 3])</code></pre><p>Both, <code>find()</code> and <code>findBy()</code> also have a fail variant too. Unlike the base version, if the fail variant cannot find a record matching your search, it will throw a 404 Not Found exception.</p><pre><code class="language-ts">// app/controllers/challenges_controller.ts
const challenges = [{ ... }]

export default class ChallengesController {
  // ...

  /**
   * Edit individual record
   */
  async edit({ params, view }: HttpContext) {
    // find the challenge being edited by its id
++    const challenge = await Challenge.findOrFail(params.id)

    // pass the challenge to the view
    return view.render('pages/challenges/edit', { challenge })
  }

  // ...
}</code></pre><h2>Updating Data</h2><p>When it comes to updating data, it is relatively similar to creating. The primary difference is that we'll want to query for the record instead of instantiating a new instance.</p><pre><code class="language-ts">// app/controllers/challenges_controller.ts
const challenges = [{ ... }]

export default class ChallengesController {
  // ...

  /**
   * Handle form submission for the edit action
   */
  async update({ params, request, response }: HttpContext) {
    const data = await request.validateUsing(challengeValidator)
++    const challenge = await Challenge.findOrFail(params.id)

++    challenge.merge(data)

++    await challenge.save()

    return response.redirect().toRoute('challenges.show', { id: params.id })
  }

  // ...
}</code></pre><p>Similar to the <code>create()</code> method, there's also a shorthand for updating, called <code>updateOrCreate()</code>. Note, it will fall back to creating a new record if a match cannot be found. So, only use it if you're okay with that possibility.</p><pre><code class="language-ts">// app/controllers/challenges_controller.ts
const challenges = [{ ... }]

export default class ChallengesController {
  // ...

  /**
   * Handle form submission for the edit action
   */
  async update({ params, request, response }: HttpContext) {
    const data = await request.validateUsing(challengeValidator)

++    await Challenge.updateOrCreate({ id: params.id }, data)

    return response.redirect().toRoute('challenges.show', { id: params.id })
  }

  // ...
}</code></pre><p>It will attempt to find the record with the payload provided as the first argument. If it finds a match, it'll update the matched record with the data provided in the second argument. If it does not find a match, it'll merge the first and second arguments together to create a record.</p><p>Again, like <code>create()</code>, there's also an <code>updateOrCreateMany()</code> version. It accepts an array of data to update or create with.</p><pre><code class="language-ts">const challenges = await Challenge.updateOrCreate({ text: 'Test' }, [dataOne, dataTwo])</code></pre><h2>Deleting Records</h2><p>Deleting records in AdonisJS is rather straightforward; we just need to query for the record and call it's <code>delete()</code> method. Once run, it'll delete that record from our database.</p><pre><code class="language-ts">// app/controllers/challenges_controller.ts
export default class ChallengesController {
  // ...

  /**
   * Delete record
   */
  async destroy({ params, response, session }: HttpContext) {
    const challenge = await Challenge.findOrFail(params.id)

    await challenge.delete()

    session.flash('success', `${challenge.text} has been deleted`)

    return response.redirect().toRoute('challenges.index')
  }
}</code></pre><p>Next, let's add a form on our <code>challenges.show</code> page to let us easily delete. I'm going to put the button outside of the form so we don't need to bugger with tweaking any of the starter kit's CSS.</p><pre><code class="language-edge">// resources/views/pages/challenges/show.edge
@layout()

  &lt;div class="hero"&gt;
    &lt;h1&gt;{{ challenge.text }}&lt;/h1&gt;
    &lt;p&gt;Points: {{ challenge.points }}&lt;/p&gt;

    &lt;a href="{{ editUrl }}" class="button"&gt;Edit Challenge&lt;/a&gt;
    &lt;button type="submit" form="destroy" class="button destructive"&gt;Delete&lt;/button&gt;
  &lt;/div&gt;

  @!form({ id: 'destroy', route: 'challenges.destroy', routeParams: { id: challenge.id }, method: 'DELETE' })

@end</code></pre><p>With that added, if we click our "Delete" button, we should see our success toast and be redirected back to the <code>challenges.index</code> page! However, one thing to note. When we delete, we need to ensure our foreign key constraints are enforced; otherwise, the delete will fail.</p><p>We could do this by cascading the delete within our database. That can be done via the migration, like below.</p><pre><code class="language-ts">// database/migrations/xxxxxx_create_challenge_users_table.ts
import { BaseSchema } from '@adonisjs/lucid/schema'

export default class extends BaseSchema {
  protected tableName = 'challenge_user'

  async up() {
    this.schema.createTable(this.tableName, (table) =&gt; {
      table.increments('id')
      table
        .integer('challenge_id')
        .unsigned()
        .references('challenges.id')
        .notNullable()
++        .onDelete('CASCADE')
      table
        .integer('user_id')
        .unsigned()
        .references('users.id')
        .notNullable()
++        .onDelete('CASCADE')

      table.timestamp('completed_at')
      table.timestamp('created_at')
      table.timestamp('updated_at')
    })
  }

  async down() {
    this.schema.dropTable(this.tableName)
  }
}</code></pre><p>With this, when we delete a challenge any <code>challenge_user</code> records using that challenge's ID will also automatically be deleted.</p><p>So, you could choose that option, or you could manually handle the foreign key references when deleting. For that, we can reach through to specific relationships using the <code>related()</code> method on our model instance and detach or delete related records.</p><pre><code class="language-ts">// app/controllers/challenges_controller.ts
export default class ChallengesController {
  // ...

  /**
   * Delete record
   */
  async destroy({ params, response, session }: HttpContext) {
    const challenge = await Challenge.findOrFail(params.id)

++    await challenge.related('participants').detach()
    await challenge.delete()

    session.flash('success', `${challenge.text} has been deleted`)

    return response.redirect().toRoute('challenges.index')
  }
}</code></pre><p>In most databases, the foreign key references must be deleted before the record itself can be deleted, so we'll detach before we delete the record. We'll be discussing working with relationships in a dedicated lesson, so more on that later.</p>]]></content:encoded>
      </item>
      <item>
        <title><![CDATA[Models, Schema Classes, & Relationships]]></title>
        <link>https://adocasts.com/lessons/models-schema-classes-and-relationships</link>
        <guid>https://adocasts.com/lessons/models-schema-classes-and-relationships</guid>
        <pubDate>Fri, 24 Apr 2026 11:00:00 +0000</pubDate>
        <description><![CDATA[Learn AdonisJS models and generated schema classes. We'll discuss model hooks, getters, computed properties, relationships, and how models can use schema classes as a base to automatically keep model columns up-to-date with our database.]]></description>
        <content:encoded><![CDATA[<p>In the last lesson, we saw that AdonisJS generates schema classes when we run and rollback our migrations. So, what are schemas?</p><h2>What Are Schemas Classes?</h2><p>New in AdonisJS 7, <a target="_blank" rel="nofollow noopener noreferrer" href="https://lucid.adonisjs.com/docs/schema-classes">schema classes</a> are generated from a migration-first approach to represent the tables in our database after migration. Though they're auto-generated, they are meant to be used and don't merely represent types. For that reason, they aren't housed within our&nbsp;<code>.adonisjs</code>&nbsp;folder, but instead within&nbsp;<code>database/schema.ts</code>.</p><p>This&nbsp;<code>schema.ts</code>&nbsp;file will contain exported schema classes for all the tables within our database. Each class describes a table's columns and the column's types. For example, here is our&nbsp;<code>UserSchema</code>&nbsp;describing our&nbsp;<code>users</code>&nbsp;table.</p><pre><code class="language-ts">// database/schema.ts
export class UserSchema extends BaseModel {
  static $columns = ['id', 'roleId', 'fullName', 'email', 'password', 'createdAt', 'updatedAt'] as const
  $columns = UserSchema.$columns
  @column({ isPrimary: true })
  declare id: number
  @column()
  declare roleId: number
  @column()
  declare fullName: string | null
  @column()
  declare email: string
  @column({ serializeAs: null })
  declare password: string
  @column.dateTime({ autoCreate: true })
  declare createdAt: DateTime
  @column.dateTime({ autoCreate: true, autoUpdate: true })
  declare updatedAt: DateTime | null
}</code></pre><p>The&nbsp;<code>@column()</code>&nbsp;decorators on top of each property notes that the property represents a column in our database. Any properties without the&nbsp;<code>@column()</code>&nbsp;decorator are then what we call not-mapped, meaning they aren't mapped to the database directly. Our database convention is to use snake case, but the JavaScript convention is to use camelCase in code. Lucid is already expecting both of these conventions and will handle that transition from snake case in our database to camel case in our models without issue.</p><p>Nullable columns are noted via their type.</p><pre><code class="language-ts">@column()
declare fullName: string | null</code></pre><p>The primary key column is specially noted for the ORM's convenience.</p><pre><code class="language-ts">@column({ isPrimary: true })
declare id: number</code></pre><p>We can omit a property from all serializations by setting the&nbsp;<code>serializeAs</code>&nbsp;property of the&nbsp;<code>@column()</code>&nbsp;decorator's config to&nbsp;<code>null</code>.</p><pre><code class="language-ts">@column({ serializeAs: null })
declare password: string</code></pre><p>Finally, we can mutate the&nbsp;<code>timestamp</code>&nbsp;columns to Luxon DateTimes by adding&nbsp;<code>@column.dateTime()</code>.</p><pre><code class="language-ts">@column.dateTime({ autoCreate: true, autoUpdate: true })
declare updatedAt: DateTime | null</code></pre><p>On these, setting&nbsp;<code>autoCreate</code>&nbsp;to true will auto-set the value to the current date and time when a record is created or inserted into our table. Then, setting&nbsp;<code>autoUpdate</code>&nbsp;to true will auto-update the value to the current date and time when a record is updated within the table.</p><p>You might've also noticed these schema classes are extending a&nbsp;<code>BaseModel</code>&nbsp;class. This&nbsp;<code>BaseModel</code>&nbsp;is the powerhouse of the ORM, taking this mapping and making it usable so we can query or mutate data within the table using what will eventually be our models.</p><h2>What Are Models?</h2><p><a target="_blank" rel="nofollow noopener noreferrer" href="https://lucid.adonisjs.com/docs/models">Models</a> are a specialized class that represents a single table within our database. Properties within the class represent columns of the table. Finally, an instance of the class represents a single row of data within the table.</p><p>These model representations act as a translator, allowing us to describe the queries or operations in TypeScript. It then converts it to SQL, executes it, and translates the results back into TypeScript for us to use.</p><p>Let's use the Ace CLI's&nbsp;<code>make:model</code>&nbsp;command to create a model for our&nbsp;<code>roles</code>&nbsp;table.</p><pre><code class="language-bash">node ace make:model role
# DONE:    create app/models/role.ts</code></pre><p>Since an instance of a model represents a single row, the convention is to use a singular name for the model.</p><p>Also, with the&nbsp;<code>make:model</code>&nbsp;command, we can create additional things like migrations, controllers, and factories!</p><pre><code class="language-bash">&gt; $ node ace make:model --help

Description:
  Make a new Lucid model

Usage:
  node ace make:model [options] [--] &lt;name&gt;

Arguments:
  name              Name of the model class

Options:
  -m, --migration   Generate the migration for the model
  -c, --controller  Generate the controller for the model
  -f, --factory     Generate a factory for the model</code></pre><p>As noted by our&nbsp;<code>DONE</code>&nbsp;statement from our&nbsp;<code>make:model</code>&nbsp;command, we can find our models within the&nbsp;<code>app/models/</code>&nbsp;directory. If we open our&nbsp;<code>Role</code>&nbsp;model, we should see the following.</p><pre><code class="language-ts">// app/models/role.ts
import { RoleSchema } from '#database/schema'

export default class Role extends RoleSchema {
}</code></pre><p>As you can see, it's merely a class extending our&nbsp;<code>RoleSchema</code>.</p><pre><code class="language-ts">// database/schema.ts
export class RoleSchema extends BaseModel {
  static $columns = ['id', 'name', 'createdAt', 'updatedAt'] as const
  $columns = RoleSchema.$columns
  @column({ isPrimary: true })
  declare id: number
  @column()
  declare name: string
  @column.dateTime({ autoCreate: true })
  declare createdAt: DateTime | null
  @column.dateTime({ autoCreate: true, autoUpdate: true })
  declare updatedAt: DateTime | null
}</code></pre><p>The&nbsp;<code>RoleSchema</code>'s job is to describe the columns of our&nbsp;<code>roles</code>&nbsp;table. It's housed within a class of its own, so it can be easily auto-generated without breaking the other mappings we'll add to our model. If you have any columns that need to be altered from their auto-generated schema representation, they can be overridden inside our model.</p><p>For example, say our role's&nbsp;<code>name</code>&nbsp;column was&nbsp;<code>role_name</code>&nbsp;in the database, but we wanted to just call it&nbsp;<code>name</code>&nbsp;in the model, we could do the following.</p><pre><code class="language-ts">// app/models/role.ts
import { RoleSchema } from '#database/schema'
++import { column, hasMany } from '@adonisjs/lucid/orm'
import type { HasMany } from '@adonisjs/lucid/types/relations'
import User from './user.ts'

export default class Role extends RoleSchema {
++  @column({ columnName: 'role_name' })
++  declare name: string

  @hasMany(() =&gt; User)
  declare users: HasMany&lt;typeof User&gt;
}</code></pre><h2>Model Relationships</h2><p>So, what are those other mappings we'll add to our model? One mapping we'll have is for relationships. <a target="_blank" rel="nofollow noopener noreferrer" href="https://lucid.adonisjs.com/docs/relationships">Relationship mappings</a> allow us to describe that a user has one role and a role can have many users, for example.</p><p>This ultimately will allow us to then query a user record and load it along with its role record, so we can display the role via&nbsp;<code>user.role.name</code>. Or, conversely, a role with an array of its users.</p><p>In our&nbsp;<code>Role</code>&nbsp;model, we can do this by adding a&nbsp;<code>users</code>&nbsp;property and decorating it with Lucid's&nbsp;<code>@hasMany()</code>&nbsp;decorator. This decorator is then passed in, via callback, the related model (our&nbsp;<code>User</code>). Each relationship also has its own type that accepts a generic of the type of the related model as well.</p><pre><code class="language-ts">import { RoleSchema } from '#database/schema'
++import { hasMany } from '@adonisjs/lucid/orm'
++import type { HasMany } from '@adonisjs/lucid/types/relations'
++import User from './user.ts'

export default class Role extends RoleSchema {
++  @hasMany(() =&gt; User)
++  declare users: HasMany&lt;typeof User&gt;
}</code></pre><p>AdonisJS has a convention it follows for defaults on our relationship, all of which can be overwritten by providing them as options in the second argument of the decorator.</p><ul><li><p><code>localKey</code>&nbsp;- the local primary key, defaulting to the primary key of the parent model,&nbsp;<code>id</code>&nbsp;in our case.</p></li><li><p><code>foreignKey</code>&nbsp;- the related primary key, defaulting to a camel case concatenation of the parent model name followed by its primary key,&nbsp;<code>roleId</code>&nbsp;in our case.</p></li></ul><p>There are five different types of relationships:</p><ol><li><p><code>hasOne</code>&nbsp;- a user has one profile</p></li><li><p><code>hasMany</code>&nbsp;- a role has many users</p></li><li><p><code>belongsTo</code>&nbsp;- a profile belongs to a user</p></li><li><p><code>manyToMany</code>&nbsp;- a challenge has many users, and a user can have many challenges</p></li><li><p><code>hasManyThrough</code>&nbsp;- a role can have created many challenges through its user</p></li></ol><p>All of these relationship options, except those prefixed with&nbsp;<code>pivot</code>, like&nbsp;<code>pivotForeignKey</code>, should use our model's name for the column (<code>roleId</code>) instead of the table's name (<code>role_id</code>). The&nbsp;<code>pivot</code>&nbsp;options are an exception because, as we'll see in a bit, pivot tables are represented by a many-to-many relationship instead of a model.</p><p>Let's go ahead and jump over to our&nbsp;<code>User</code>&nbsp;model to describe the inverse side of this relationship. You'll notice there's more going on with our&nbsp;<code>User</code>&nbsp;model. That's okay, for now, let's focus on the task at hand!</p><pre><code class="language-ts">// app/models/user.ts
import { UserSchema } from '#database/schema'
import { withAuthFinder } from '@adonisjs/auth/mixins/lucid'
import { compose } from '@adonisjs/core/helpers'
import hash from '@adonisjs/core/services/hash'
++import { belongsTo } from '@adonisjs/lucid/orm'
++import type { BelongsTo } from '@adonisjs/lucid/types/relations'
++import Role from './role.ts'

/**
 * User model represents a user in the application.
 * It extends UserSchema and includes authentication capabilities
 * through the withAuthFinder mixin.
 */
export default class User extends compose(UserSchema, withAuthFinder(hash)) {
++  @belongsTo(() =&gt; Role)
++  declare role: BelongsTo&lt;typeof Role&gt;

  /**
   * Get the user's initials from their full name or email.
   * Returns the first letter of first and last name if available,
   * otherwise returns the first two characters of the email username.
   */
  get initials() {
    const [first, last] = this.fullName ? this.fullName.split(' ') : this.email.split('@')
    if (first &amp;&amp; last) {
      return `${first.charAt(0)}${last.charAt(0)}`.toUpperCase()
    }
    return `${first.slice(0, 2)}`.toUpperCase()
  }
}</code></pre><p>Typically, if the relationship ID is described on the model, it'll be a&nbsp;<code>BelongsTo</code>&nbsp;relationship. Since our&nbsp;<code>Users</code>&nbsp;model consists of a&nbsp;<code>roleId</code>&nbsp;column, that's exactly the case here.</p><h2>Getters &amp; Computed Properties</h2><p>Okay, while we're on our&nbsp;<code>User</code>&nbsp;model, let's talk about&nbsp;<code>get initials()</code>. This is a getter, and it'll act just like a property, though executing the method when used to return the value. These are non-mapped properties, meaning they aren't present in our database, that we can add onto our models to aid in development. Note, getters cannot be asynchronous; this is a limitation of the language, not the framework.</p><p>Like getters, we can define whatever properties or methods we want on our models. So long as the&nbsp;<code>@column()</code>&nbsp;and relationship decorators are omitted, they'll be treated as non-mapped properties.</p><p>They are, however, omitted when the model is serialized by default. So, when our models are converted to JSON, for example, these properties will be omitted. If we want them to be included, we can decorate them with the&nbsp;<code>@computed()</code>&nbsp;decorator.</p><pre><code class="language-ts">// app/models/user.ts
import { UserSchema } from '#database/schema'
import { withAuthFinder } from '@adonisjs/auth/mixins/lucid'
import { compose } from '@adonisjs/core/helpers'
import hash from '@adonisjs/core/services/hash'
++import { belongsTo, computed } from '@adonisjs/lucid/orm'
import type { BelongsTo } from '@adonisjs/lucid/types/relations'
import Role from './role.ts'

/**
 * User model represents a user in the application.
 * It extends UserSchema and includes authentication capabilities
 * through the withAuthFinder mixin.
 */
export default class User extends compose(UserSchema, withAuthFinder(hash)) {
  @belongsTo(() =&gt; Role)
  declare role: BelongsTo&lt;typeof Role&gt;

  /**
   * Get the user's initials from their full name or email.
   * Returns the first letter of first and last name if available,
   * otherwise returns the first two characters of the email username.
   */
++  @computed()
  get initials() {
    const [first, last] = this.fullName ? this.fullName.split(' ') : this.email.split('@')
    if (first &amp;&amp; last) {
      return `${first.charAt(0)}${last.charAt(0)}`.toUpperCase()
    }
    return `${first.slice(0, 2)}`.toUpperCase()
  }
}</code></pre><p>With this decorator added to the property, it informs AdonisJS to keep the property within the serialized result.</p><p>Great, while we're here, let's create our remaining two models and fill in their relationships.</p><pre><code class="language-bash">node ace make:model profile
# DONE:    create app/models/profile.ts</code></pre><pre><code class="language-bash">node ace make:model challenges
# DONE:    create app/models/challenge.ts</code></pre><p>Again, we won't be creating a model for our&nbsp;<code>challenge_user</code>&nbsp;pivot table. Pivot tables can be omitted and worked with directly via their many-to-many relationship within the ORM.</p><p>Our&nbsp;<code>Profile</code>&nbsp;model has the&nbsp;<code>userId</code>&nbsp;column on it, so our profile belongs to a user.</p><pre><code class="language-ts">import { ProfileSchema } from '#database/schema'
++import { belongsTo } from '@adonisjs/lucid/orm'
++import User from './user.ts'
++import type { BelongsTo } from '@adonisjs/lucid/types/relations'

export default class Profile extends ProfileSchema {
++  @belongsTo(() =&gt; User)
++  declare user: BelongsTo&lt;typeof User&gt;
}</code></pre><p>Then, for our&nbsp;<code>Challenge</code>&nbsp;model, challenges belong to a user in two different ways.</p><ol><li><p>They're created by a user, tracked via the&nbsp;<code>creatorId</code></p></li><li><p>They have users who've joined the challenge via our&nbsp;<code>challenge_user</code>&nbsp;pivot table</p></li></ol><pre><code class="language-ts">// app/models/challenge.ts
import { ChallengeSchema } from '#database/schema'
++import { belongsTo, manyToMany } from '@adonisjs/lucid/orm'
++import type { BelongsTo, ManyToMany } from '@adonisjs/lucid/types/relations'
++import User from './user.ts'

export default class Challenge extends ChallengeSchema {
++  @belongsTo(() =&gt; User, {
++    foreignKey: 'creatorId',
++  })
++  declare createdBy: BelongsTo&lt;typeof User&gt;

++  @manyToMany(() =&gt; User)
++  declare participants: ManyToMany&lt;typeof User&gt;
}</code></pre><p>Since we're straying from the default naming convention of&nbsp;<code>userId</code>&nbsp;(parent model name + primary key) with our&nbsp;<code>creatorId</code>, we need to manually specify that as the foreign key for our&nbsp;<code>BelongsTo</code>&nbsp;relationship. Also, we can call the property name whatever we'd like for relationships, which gives us nice flexibility to be descriptive.</p><p>Then, for our&nbsp;<code>ManyToMany</code>, the options look a little different, should you need to use them.</p><ul><li><p><code>localKey</code>&nbsp;- primary key of the current model</p></li><li><p><code>pivotForeignKey</code>&nbsp;- pivot table's foreign key for current model&nbsp;<em>(use the table column name)</em></p></li><li><p><code>relatedKey</code>&nbsp;- primary key of the related model</p></li><li><p><code>pivotRelatedForeignKey</code>&nbsp;- pivot table's foreign key for the related model&nbsp;<em>(use the table column name)</em></p></li></ul><p>We don't need to, because we match the default convention, but if we manually describe this relationship's keys, it'd look like this:</p><pre><code class="language-ts">// app/models/challenge.ts
export default class Challenge extends ChallengeSchema {
  // ...

  @manyToMany(() =&gt; User, {
    localKey: 'id',
    pivotForeignKey: 'challenge_id',
    relatedKey: 'id',
    pivotRelatedForeignKey: 'user_id',
  })
  declare participants: ManyToMany&lt;typeof User&gt;
}</code></pre><p>Finally, we can fill in the remaining relationships of our&nbsp;<code>User</code>&nbsp;model.</p><pre><code class="language-ts">import { UserSchema } from '#database/schema'
import { withAuthFinder } from '@adonisjs/auth/mixins/lucid'
import { compose } from '@adonisjs/core/helpers'
import hash from '@adonisjs/core/services/hash'
++import { belongsTo, computed, hasMany, hasOne, manyToMany } from '@adonisjs/lucid/orm'
++import type { BelongsTo, HasMany, HasOne, ManyToMany } from '@adonisjs/lucid/types/relations'
++import Challenge from './challenge.ts'
++import Profile from './profile.ts'
import Role from './role.ts'

/**
 * User model represents a user in the application.
 * It extends UserSchema and includes authentication capabilities
 * through the withAuthFinder mixin.
 */
export default class User extends compose(UserSchema, withAuthFinder(hash)) {
  @belongsTo(() =&gt; Role)
  declare role: BelongsTo&lt;typeof Role&gt;

++  @hasOne(() =&gt; Profile)
++  declare profile: HasOne&lt;typeof Profile&gt;

++  @hasMany(() =&gt; Challenge, {
++    foreignKey: 'creatorId',
++  })
++  declare createdChallenges: HasMany&lt;typeof Challenge&gt;

++  @manyToMany(() =&gt; Challenge)
++  declare challenges: ManyToMany&lt;typeof Challenge&gt;

  /**
   * Get the user's initials from their full name or email.
   * Returns the first letter of first and last name if available,
   * otherwise returns the first two characters of the email username.
   */
  @computed()
  get initials() {
    const [first, last] = this.fullName ? this.fullName.split(' ') : this.email.split('@')
    if (first &amp;&amp; last) {
      return `${first.charAt(0)}${last.charAt(0)}`.toUpperCase()
    }
    return `${first.slice(0, 2)}`.toUpperCase()
  }
}</code></pre><p>Again, since our&nbsp;<code>creatorId</code>&nbsp;goes against the default naming convention, we'll specify the foreign key as&nbsp;<code>creatorId</code>&nbsp;here.</p><h2>Model Hooks</h2><p>Finally, although we haven't talked about create, read, update, or delete (CRUD) operations, let's take a moment to learn about <a target="_blank" rel="nofollow noopener noreferrer" href="https://lucid.adonisjs.com/docs/model-hooks">hooks</a>. They are specialized static methods defined on our models that can hook into certain operations being performed with our models. For example, we could hook into our&nbsp;<code>Profile</code>&nbsp;model to mutate it before it is persisted to the database.</p><pre><code class="language-ts">import { ProfileSchema } from '#database/schema'
++import { beforeSave, belongsTo } from '@adonisjs/lucid/orm'
import type { BelongsTo } from '@adonisjs/lucid/types/relations'
import User from './user.ts'

export default class Profile extends ProfileSchema {
  @belongsTo(() =&gt; User)
  declare user: BelongsTo&lt;typeof User&gt;

++  @beforeSave()
++  static onBeforeSave(profile: Profile) {
++    if (profile.$dirty.bio &amp;&amp; !profile.bio) {
++      profile.bio = '[no bio]'
++    }
++  }
}</code></pre><p>The name of the method doesn't matter; all that matters is that the method is static and is decorated with&nbsp;<code>@beforeSave()</code>. With that, Lucid will run the method before creating or updating a profile record in our database.</p><p>A little redundant, but we can also use special properties on our model, like&nbsp;<code>$dirty</code>&nbsp;for example, to see if one of our columns has been altered at all.</p><h4>Available Hooks</h4><p>All of the below hooks can be async, and all are provided the model instance/row being mutated.</p><ul><li><p><code>@beforeSave()</code>&nbsp;- run before an insert or update operation</p></li><li><p><code>@afterSave()</code>&nbsp;- run after an insert or update operation</p></li><li><p><code>@beforeCreate()</code>&nbsp;- run before an insert operation</p></li><li><p><code>@afterCreate()</code>&nbsp;- run after an insert operation</p></li><li><p><code>@beforeUpdate()</code>&nbsp;- run before an update operation</p></li><li><p><code>@afterUpdate()</code>&nbsp;- run after an update operation</p></li><li><p><code>@beforeDelete()</code>&nbsp;- run before a delete operation</p></li><li><p><code>@afterDelete()</code>&nbsp;- run after a delete operation</p></li></ul><p>There are also query-based hooks that receive either the query (before) or the results of the query (after).</p><ul><li><p><code>@beforeFind()</code>&nbsp;- run before find queries</p></li><li><p><code>@afterFind()</code>&nbsp;- run after find queries</p></li><li><p><code>@beforeFetch()</code>&nbsp;- run before fetch queries</p></li><li><p><code>@afterFetch()</code>&nbsp;- run after fetch queries</p></li><li><p><code>@beforePaginate()</code>&nbsp;- run before pagination queries</p></li><li><p><code>@afterPaginate()</code>&nbsp;- run after pagination queries</p></li></ul>]]></content:encoded>
      </item>
      <item>
        <title><![CDATA[Database Migrations]]></title>
        <link>https://adocasts.com/lessons/database-migrations</link>
        <guid>https://adocasts.com/lessons/database-migrations</guid>
        <pubDate>Fri, 24 Apr 2026 11:00:00 +0000</pubDate>
        <description><![CDATA[Learn database migrations in AdonisJS and how they can be used to scaffold and build upon our application's database. Track, share, and rollback database changes with version control for your schema.]]></description>
        <content:encoded><![CDATA[<p><a target="_blank" rel="nofollow noopener noreferrer" href="https://lucid.adonisjs.com/docs/migrations">Migrations</a> are like version control for our database. They allow us to track, share, and rollback changes to our database structure, like adding a table or changing a column type. They're great for many reasons, but two standout reasons are.</p><ol><li><p>They allow teammates to easily onboard into the project and/or keep their development database up-to-date.</p></li><li><p>They allow code changes to go out in unison with database changes in our production environment.</p></li></ol><h2>Migration Basics</h2><p><a target="_blank" rel="nofollow noopener noreferrer" href="https://lucid.adonisjs.com/docs/migrations">AdonisJS migrations</a> can be found within the&nbsp;<code>database/migrations</code>&nbsp;directory, and if we look there, we can see our starter kit has given us one to start with. You might note its filename:</p><pre><code>database/migrations/1758943358073_create_users_table.ts</code></pre><p>The number at the beginning of the filename,&nbsp;<code>1758943358073</code>, is a timestamp of when the migration was created. This is used to naturally sort our migrations, both in our migrations folder and in the order they'll run, giving us a consistent and reliable order for them.</p><p>After the timestamp, is the action of the migration,&nbsp;<code>create_users_table</code>, noting that this migration creates the&nbsp;<code>users</code>&nbsp;table within our database. This is purely for informative reasons to help us find specific migrations in our file tree; these may be as simple as the table's name.</p><p>Migrations are comprised of a class extending a&nbsp;<code>BaseSchema</code>&nbsp;from Lucid, AdonisJS's Object Relational Mapping (ORM). ORMs are like a translator, taking an object-like representation we'll use in TypeScript and translating it to the relational tables in our database. This gives us simplified syntax for things like querying data or even building tables, like we'll do with migrations.</p><pre><code class="language-ts">// database/migrations/xxxxx_create_users_table.ts
import { BaseSchema } from '@adonisjs/lucid/schema'

export default class extends BaseSchema {
  protected tableName = 'users'

  async up() {
    this.schema.createTable(this.tableName, (table) =&gt; {
      table.increments('id').notNullable()
      table.string('full_name').nullable()
      table.string('email', 254).notNullable().unique()
      table.string('password').notNullable()

      table.timestamp('created_at').notNullable()
      table.timestamp('updated_at').nullable()
    })
  }

  async down() {
    this.schema.dropTable(this.tableName)
  }
}</code></pre><p>Within the migration class are two methods,&nbsp;<code>up</code>&nbsp;and&nbsp;<code>down</code>. The&nbsp;<code>up</code>&nbsp;method is forward progress. This is where we'll create new tables, constraints, or any other changes to our database we want to apply. The&nbsp;<code>down</code>&nbsp;method allows us to go backward, or undo anything we've done in the&nbsp;<code>up</code>&nbsp;method.</p><p>Whatever we do in&nbsp;<code>up</code>&nbsp;we should undo in&nbsp;<code>down</code>. For example, in the&nbsp;<code>users</code>&nbsp;migration above, the&nbsp;<code>up</code>&nbsp;method creates the&nbsp;<code>users</code>&nbsp;table within our database, while the&nbsp;<code>down</code>&nbsp;method drops the&nbsp;<code>users</code>&nbsp;table.</p><p>This flow is primarily to aid in development, allowing incremental progress or change without having to create tons of migrations for a single feature. The&nbsp;<code>down</code>&nbsp;method is destructive and shouldn't be run in production, and AdonisJS will require confirmation in order to do so.</p><h2>Running Migrations</h2><p>When a migration is run, the&nbsp;<code>up</code>&nbsp;method will be executed. We can easily run migrations using the Ace CLI's&nbsp;<code>migration:run</code>&nbsp;command.</p><pre><code class="language-bash">node ace migration:run
# [ error ] Cannot open database because the directory does not exist</code></pre><p>When we first try to run our migration, we may see an error that the directory does not exist. By default, AdonisJS will use SQLite as its database. This is a file-based database that will be housed within our project's temporary directory (<code>tmp</code>). If you get this error, it's because that directory does not exist, so let's create it real quick!</p><pre><code class="language-bash">mkdir tmp</code></pre><p>Then, let's try our migration once more.</p><pre><code class="language-bash">node ace migration:run
# "sqlite" (112 μs) SELECT `version` FROM `adonis_schema_versions` LIMIT ? [ 1 ]
# "sqlite" (627 μs) INSERT INTO `adonis_schema_versions` (`version`) VALUES (?) [ 1 ]
# [ info ] Upgrading migrations version from "1" to "2"
# "sqlite" (56 μs) SELECT `name`, `batch`, `migration_time`, `id` FROM `adonis_schema` WHERE `batch` &gt; ? ORDER BY `id` DESC [ 0 ]
# "sqlite" (219 μs) UPDATE `adonis_schema_versions` SET `version` = ? WHERE `version` = ? [ 2, 1 ]
# "sqlite" (113 μs) SELECT MAX(`batch`) AS `batch` FROM `adonis_schema` []
# "sqlite" (44 μs) SELECT `name` FROM `adonis_schema` []
# ❯ migrating database/migrations/1758943358073_create_users_table
# (471 μs) CREATE TABLE `users` (`id` INTEGER NOT NULL PRIMARY KEY autoincrement, `full_name` VARCHAR(255) NULL, `email` VARCHAR(254) NOT NULL, `password` VARCHAR(255) NOT NULL, `created_at` DATETIME NOT NULL, `updated_at` DATETIME NULL)
# ❯ migrated database/migrations/1758943358073_create_users_table
# 
# Migrated in 53 ms
# "sqlite" (127 μs) SELECT `name` AS `table_name` FROM `sqlite_master` WHERE `type` = ? AND NOT `name` LIKE ? ORDER BY `name` ASC [ 'table', 'sqlite_%' ]
# ❯ Creating schema classes for scanned tables
# [ success ] Schema classes generated (4.63 ms)</code></pre><p>A lot of information is printed out, detailing every SQL command the migration executed on our behalf. You can see that it first checks the&nbsp;<code>adonis_schema_versions</code>&nbsp;table. This is a table used to track Lucid's schema versioning; we don't need to worry about it.</p><pre><code class="language-bash">"sqlite" (112 μs) SELECT `version` FROM `adonis_schema_versions` LIMIT ? [ 1 ]
"sqlite" (627 μs) INSERT INTO `adonis_schema_versions` (`version`) VALUES (?) [ 1 ]
[ info ] Upgrading migrations version from "1" to "2"
"sqlite" (219 μs) UPDATE `adonis_schema_versions` SET `version` = ? WHERE `version` = ? [ 2, 1 ]</code></pre><p>Next, it checks for the last batch that was run. When we run our migrations, Lucid will run any migration that hasn't yet run. All migrations processed in a single run are considered a batch. It keeps track of all this information in a table called&nbsp;<code>adonis_schema</code>.</p><pre><code class="language-bash">"sqlite" (56 μs) SELECT `name`, `batch`, `migration_time`, `id` FROM `adonis_schema` WHERE `batch` &gt; ? ORDER BY `id` DESC [ 0 ]
"sqlite" (113 μs) SELECT MAX(`batch`) AS `batch` FROM `adonis_schema` []
"sqlite" (44 μs) SELECT `name` FROM `adonis_schema` []</code></pre><p>Using that info, it determines which migrations within our&nbsp;<code>database/migrations</code>&nbsp;directory need to run, skipping those that have already run.</p><p>Small note, the question marks and square brackets in these queries are SQL Parameterization. A precautionary step that protects our database against SQL Injection Attacks. For example, it prevents someone from being able to drop a table in our database via an input shown on our web page. The hard brackets represent an array, and it works left to right to fill in each question mark placeholder within the query.</p><pre><code class="language-sql">-- so a parameterized query, like this:
UPDATE `adonis_schema_versions` SET `version` = ? WHERE `version` = ? [ 2, 1 ]
-- will get run by SQL as this:
UPDATE `adonis_schema_versions` SET `version` = 2 WHERE `version` = 1</code></pre><p>Next, it runs the&nbsp;<code>up</code>&nbsp;method from our migration.</p><pre><code class="language-bash">❯ migrated database/migrations/1758943358073_create_users_table
 
Migrated in 53 ms</code></pre><p>Finally, it generates schema classes from our database, we'll discuss these more in the next lesson.</p><pre><code class="language-bash">"sqlite" (127 μs) SELECT `name` AS `table_name` FROM `sqlite_master` WHERE `type` = ? AND NOT `name` LIKE ? ORDER BY `name` ASC [ 'table', 'sqlite_%' ]
❯ Creating schema classes for scanned tables
[ success ] Schema classes generated (4.63 ms)</code></pre><p>At this point, our database now contains an empty&nbsp;<code>users</code>&nbsp;table!</p><p>By the way, we'll be using these queries that are printed into our terminal throughout this module to aid in our understanding of what's happening. If, however, at any point you want to turn these off, you can easily do this within your database config.</p><pre><code class="language-ts">// config/database.ts
import app from '@adonisjs/core/services/app'
import { defineConfig } from '@adonisjs/lucid'

const dbConfig = defineConfig({
  // ...

  connections: {
    sqlite: {
      // ...

      /**
       * Enable SQL query debugging in development mode.
       */
++      debug: app.inDev,
    },
  },
})

export default dbConfig</code></pre><h2>Rolling Back</h2><p>When a migration is rolled back, the&nbsp;<code>down</code>&nbsp;method is run that should undo whatever was done in the migration's&nbsp;<code>up</code>&nbsp;method. Again, there is an Ace CLI command for this called&nbsp;<code>migration:rollback</code>.</p><pre><code class="language-bash">node ace migration:rollback
# "sqlite" (119 μs) SELECT `version` FROM `adonis_schema_versions` LIMIT ? [ 1 ]
# "sqlite" (177 μs) SELECT MAX(`batch`) AS `batch` FROM `adonis_schema` []
# "sqlite" (84 μs) SELECT `name`, `batch`, `migration_time`, `id` FROM `adonis_schema` WHERE `batch` &gt; ? ORDER BY `id` DESC [ 0 ]
# ❯ reverting database/migrations/1758943358073_create_users_table
# (905 μs) DROP TABLE `users`
# ❯ reverted database/migrations/1758943358073_create_users_table
# 
# Reverted in 27 ms
# "sqlite" (148 μs) SELECT `name` AS `table_name` FROM `sqlite_master` WHERE `type` = ? AND NOT `name` LIKE ? ORDER BY `name` ASC [ 'table', 'sqlite_%' ]
# ❯ Creating schema classes for scanned tables
# [ success ] Schema classes generated (2.43 ms)</code></pre><p>Rollbacks occur one batch at a time, unless otherwise specified, so as we can see, it's querying for the max batch number in our&nbsp;<code>adonis_schema</code>&nbsp;table to then get all migrations in that batch.</p><pre><code class="language-bash">"sqlite" (177 μs) SELECT MAX(`batch`) AS `batch` FROM `adonis_schema` []
"sqlite" (84 μs) SELECT `name`, `batch`, `migration_time`, `id` FROM `adonis_schema` WHERE `batch` &gt; ? ORDER BY `id` DESC [ 0 ]</code></pre><p>It'll then revert those migrations, running their&nbsp;<code>down</code>&nbsp;method.</p><pre><code class="language-bash">❯ reverting database/migrations/1758943358073_create_users_table
(905 μs) DROP TABLE `users`
❯ reverted database/migrations/1758943358073_create_users_table
 
Reverted in 27 ms</code></pre><p>Finally, again regenerating schemas from our updated database structure.</p><pre><code class="language-bash">"sqlite" (148 μs) SELECT `name` AS `table_name` FROM `sqlite_master` WHERE `type` = ? AND NOT `name` LIKE ? ORDER BY `name` ASC [ 'table', 'sqlite_%' ]
❯ Creating schema classes for scanned tables
[ success ] Schema classes generated (2.43 ms)</code></pre><p>At this point, we no longer have a&nbsp;<code>users</code>&nbsp;table within our database.</p><p>Before we move on, there are a few other Ace CLI commands to note here.</p><ul><li><p><code>migration:fresh</code>&nbsp;will drop all tables and rerun all our migrations, essentially giving us a clean data slate.</p></li><li><p><code>migration:refresh</code>&nbsp;will rollback and remigrate, again, this will do a single batch unless otherwise specified.</p></li><li><p><code>migration:reset</code>&nbsp;will roll all the way back, undoing all migrations.</p></li><li><p><code>migration:status</code>&nbsp;will read our&nbsp;<code>adonis_schema</code>&nbsp;table to print out the status of all our migrations, showing which have run and which are pending.</p></li></ul><h2>Creating Migrations</h2><p>We'll need more than just a&nbsp;<code>users</code>&nbsp;table and a migration should only consist of creations or alterations for a single table. If you need to make multiple tables or changes to multiple tables, we should create a new migration for each table we need to touch.</p><p>Like everything else, we can easily do this via the Ace CLI's&nbsp;<code>make:migration</code>&nbsp;command.</p><pre><code class="language-bash">node ace make:migration role
# DONE:    create database/migrations/1770077349836_create_roles_table.ts</code></pre><p>Notice how AdonisJS automatically normalized our table name to plural and gave us a starting create table structure with an auto-incrementing&nbsp;<code>id</code>&nbsp;in our&nbsp;<code>up</code>&nbsp;method and a&nbsp;<code>dropTable</code>&nbsp;statement in our&nbsp;<code>down</code>. Also note, convention states we should define our column names in snake case, meaning all lowercase using underscores to separate words.</p><pre><code class="language-ts">// database/migrations/xxxxx_create_roles_table.ts
import { BaseSchema } from '@adonisjs/lucid/schema'

export default class extends BaseSchema {
  protected tableName = 'roles'

  async up() {
    this.schema.createTable(this.tableName, (table) =&gt; {
      table.increments('id')

      table.timestamp('created_at')
      table.timestamp('updated_at')
    })
  }

  async down() {
    this.schema.dropTable(this.tableName)
  }
}</code></pre><p>Okay, let's add a&nbsp;<code>name</code>&nbsp;column to our&nbsp;<code>roles</code>&nbsp;table!</p><pre><code class="language-ts">// database/migrations/xxxxx_create_roles_table.ts
import { BaseSchema } from '@adonisjs/lucid/schema'

export default class extends BaseSchema {
  protected tableName = 'roles'

  async up() {
    this.schema.createTable(this.tableName, (table) =&gt; {
      table.increments('id')
++      table.string('name').notNullable()

      table.timestamp('created_at')
      table.timestamp('updated_at')
    })
  }

  async down() {
    this.schema.dropTable(this.tableName)
  }
}</code></pre><p>We start with&nbsp;<code>table</code>&nbsp;and a method to describe the column's type, like&nbsp;<code>string</code>. Inside the type method goes the column's name. Off the type method, we can chain additional alterations for the column. By default, columns will be defined as nullable, so we can make this not null by chaining the&nbsp;<code>notNullable()</code>&nbsp;method on it.</p><p>Next, we need to be able to assign a user a role. So, let's add a&nbsp;<code>role_id</code>&nbsp;column to our&nbsp;<code>create_users_table</code>&nbsp;migration.</p><pre><code class="language-ts">// database/migrations/xxxxx_create_users_table.ts
import { BaseSchema } from '@adonisjs/lucid/schema'

export default class extends BaseSchema {
  protected tableName = 'users'

  async up() {
    this.schema.createTable(this.tableName, (table) =&gt; {
      table.increments('id').notNullable()
++      table.integer('role_id').unsigned().references('id').inTable('roles').notNullable().defaultTo(1)
      table.string('full_name').nullable()
      table.string('email', 254).notNullable().unique()
      table.string('password').notNullable()

      table.timestamp('created_at').notNullable()
      table.timestamp('updated_at').nullable()
    })
  }

  async down() {
    this.schema.dropTable(this.tableName)
  }
}</code></pre><p>The column name&nbsp;<code>role_id</code>&nbsp;also comes from convention. A singular version of the referencing table (<code>role</code>), followed by that table's primary key (<code>id</code>), and joined together with an underscore.</p><p>The&nbsp;<code>unsigned()</code>&nbsp;method notes our&nbsp;<code>integer</code>&nbsp;type won't be negative. Then,&nbsp;<code>references('id').inTable('roles')</code>&nbsp;defines a foreign key constraint for&nbsp;<code>role_id</code>&nbsp;ensuring that any value inserted into it needs to exist within our&nbsp;<code>roles.id</code>. This can also be written as just&nbsp;<code>references('roles.id')</code>. Finally, we'll default the value to 1, which will eventually be our "user" role. If we create a user without a&nbsp;<code>role_id</code>&nbsp;specified, 1 will be used as the default.</p><h2>Migration Order</h2><p>Now, in most databases, the orderin which  we create our migrations does matter. For our user to have a&nbsp;<code>role_id</code>&nbsp;on its table, for example, the&nbsp;<code>roles</code>&nbsp;table must be created first. Otherwise, we won't be able to add a foreign key constraint to ensure the user isn't being assigned a role that doesn't exist. SQLite has a lazy foreign key, though, so this won't be the case. We will, however, treat it as such as good practice to do because if you switch to PostgreSQL or MySQL, it will be an issue.</p><p>We can easily fix this by adjusting the timestamp prefix manually to place our&nbsp;<code>roles</code>&nbsp;before&nbsp;<code>users</code>&nbsp;or vice versa. So, instead of:</p><pre><code>database/
└── migrations/
    ├── 1758943358073_create_users_table.ts
    └── 1770077349836_create_roles_table</code></pre><p>We can instead do:</p><pre><code>database/
└── migrations/
    ├── 1770077349836_create_roles_table.ts
    └── 1770077349837_create_users_table.ts</code></pre><p>Noting that we've just taken our&nbsp;<code>create_roles_table</code>&nbsp;timestamp, incremented it by one and applied it as our&nbsp;<code>create_users_table</code>&nbsp;timestamp to change the natural sort.</p><h2>Filling In Our Database</h2><p>Great, so let's go ahead and run through a few more migrations to fill out the complete schema structure of our database, starting with a profile for our user.</p><pre><code class="language-bash">node ace make:migration profiles
# DONE:    create database/migrations/1770079110036_create_profiles_table.ts</code></pre><p>Within this migration, we'll want each row to be bound to a user, as this will be a one-to-one relationship. One user has one profile. Then, we'll also let them have a brief bio, giving this column a max size of 255 characters. Finally, adding a boolean to determine whether their profile is public or not. We can also default this to&nbsp;<code>true</code>.</p><pre><code class="language-ts">// database/migrations/1770079110036_create_profiles_table.ts
import { BaseSchema } from '@adonisjs/lucid/schema'

export default class extends BaseSchema {
  protected tableName = 'profiles'

  async up() {
    this.schema.createTable(this.tableName, (table) =&gt; {
      table.increments('id')
      table.integer('user_id').unsigned().references('users.id').notNullable()
      table.string('bio', 255)
      table.boolean('is_public').defaultTo(true)

      table.timestamp('created_at')
      table.timestamp('updated_at')
    })
  }

  async down() {
    this.schema.dropTable(this.tableName)
  }
}</code></pre><p>Next, we'll have a&nbsp;<code>challenges</code>&nbsp;table.</p><pre><code class="language-bash">node ace make:migration challenges
# DONE:    create database/migrations/1770079110048_create_challenges_table.ts</code></pre><p>Inside of which, we'll note which user created the challenge, have&nbsp;<code>text</code>&nbsp;noting what the challenge is, and&nbsp;<code>points</code>&nbsp;noting a point value, the challenge is worth. This will be a many-to-one relationship. A user can have many challenge,s but a challenge can only be created by one user, which we'll use&nbsp;<code>creator_id</code>&nbsp;to track.</p><pre><code class="language-ts">// database/migrations/1770079110048_create_challenges_table.ts
import { BaseSchema } from '@adonisjs/lucid/schema'

export default class extends BaseSchema {
  protected tableName = 'challenges'

  async up() {
    this.schema.createTable(this.tableName, (table) =&gt; {
      table.increments('id')
      table.integer('creator_id').unsigned().references('users.id').notNullable()
      table.string('text', 255).notNullable()
      table.integer('points').notNullable()

      table.timestamp('created_at')
      table.timestamp('updated_at')
    })
  }

  async down() {
    this.schema.dropTable(this.tableName)
  }
}</code></pre><p>Finally, we'll let users join a challenge via a&nbsp;<code>challenge_user</code>&nbsp;pivot table. Pivot tables allow us to join together many-to-many relationships. Users can join many challenges, and challenges have many users that've joined.</p><pre><code class="language-bash">node ace make:migration challenge_user
# DONE:    create database/migrations/1770079119171_create_challenge_users_table.ts</code></pre><p>Inside here, we'll first want to change the table name from plural to singular. The default naming convention Lucid will look for with pivot tables is a sorted concatenation of the two related entities in singular form. So,&nbsp;<code>challenge</code>&nbsp;comes before&nbsp;<code>user</code>&nbsp;when sorted, meaning the default would want&nbsp;<code>challenge_user</code>.</p><p>In addition to that, we want one column per joined entity along with a foreign key constraint. Finally, we can track when and if a user has completed the challenge in one go using a nullable timestamp. When null, the user hasn't completed the challenge, and when not null, then the user has completed the challenge, and we know when they've done so.</p><pre><code class="language-ts">// database/migrations/1770079119171_create_challenge_users_table.ts
import { BaseSchema } from '@adonisjs/lucid/schema'

export default class extends BaseSchema {
  protected tableName = 'challenge_user'

  async up() {
    this.schema.createTable(this.tableName, (table) =&gt; {
      table.increments('id')
      table.integer('challenge_id').unsigned().references('challenges.id').notNullable()
      table.integer('user_id').unsigned().references('users.id').notNullable()

      table.timestamp('completed_at')
      table.timestamp('created_at')
      table.timestamp('updated_at')
    })
  }

  async down() {
    this.schema.dropTable(this.tableName)
  }
}</code></pre><p>Great, let's run our migrations!</p><pre><code class="language-bash">node ace migration:run</code></pre><h2>Inspecting SQLite</h2><p>You can either take my word for it that our tables are in our database, or we can see it for ourselves! Let's install a new extension in VS Code called&nbsp;<a target="_blank" rel="noopener nofollow" class="external-link" href="https://marketplace.visualstudio.com/items?itemName=qwtel.sqlite-viewer">SQLite Viewer</a>. This will allow us to click on the SQLite database in our project to see its contents.</p><p>As mentioned earlier, our SQLite database is stored within the&nbsp;<code>tmp</code>&nbsp;directory, so let's open that up and give it a click, and we should now see a list of our tables.</p><p>If we make changes to our migrations, our database won't automatically be updated to match. Instead, we'd need to rollback and rerun the migration to apply whatever changes we've made. Once a migration has hit production, the flow changes. We should instead add a new migration to make whatever change we need because rolling back migrations in production is a destructive action and can lead to data loss.</p>]]></content:encoded>
      </item>
      <item>
        <title><![CDATA[Middleware & Grouping Routes]]></title>
        <link>https://adocasts.com/lessons/middleware-and-grouping-routes</link>
        <guid>https://adocasts.com/lessons/middleware-and-grouping-routes</guid>
        <pubDate>Fri, 10 Apr 2026 11:00:00 +0000</pubDate>
        <description><![CDATA[Learn AdonisJS middleware for intercepting HTTP requests. Control request flow, implement logging, and secure your applications.]]></description>
        <content:encoded><![CDATA[<p><a target="_blank" rel="nofollow noopener noreferrer" href="https://docs.adonisjs.com/guides/basics/middleware">Middleware</a> are functions that intercept incoming HTTP requests before they reach your route handlers. They allow you to perform actions like authentication checks, logging, modifying requests or responses, and much more.</p><p>Each middleware can:</p><ul><li><p>Pass the request to the next middleware by calling&nbsp;<code>next()</code></p></li><li><p>Modify the request before passing it along</p></li><li><p>Modify the response coming back</p></li><li><p>Stop the pipeline and return a response early</p></li></ul><h2>Creating Middleware</h2><p>Let's create our own middleware to track how long requests take to process. We can use the&nbsp;<code>make:middleware</code>&nbsp;command to do this. When we run this command, it'll ask us <a target="_blank" rel="nofollow noopener noreferrer" href="https://docs.adonisjs.com/guides/basics/middleware#middleware-stacks">which type of middleware</a> we're creating.</p><ul><li><p>Server middleware will run for every request entering our application</p></li><li><p>Router middleware will run for every request hitting a defined route in our application</p></li><li><p>Named middleware will run only on routes where we specifically add them</p></li></ul><pre><code class="language-bash">node ace make:middleware request_logger
# ❯ Under which stack you want to register the middleware? · server
# DONE:    create app/middleware/request_logger_middleware.ts
# DONE:    update start/kernel.ts file</code></pre><p>Let's select&nbsp;<code>server</code>&nbsp;for now. This will create a new middleware within&nbsp;<code>app/middleware</code>&nbsp;and register this as a new server middleware within our&nbsp;<code>start/kernel.ts</code>. The kernel file is responsible for registering middleware in our application.</p><h2>Middleware Execution Flow</h2><p>Our new middleware should look like the one below.</p><pre><code class="language-ts">// app/middleware/request_logger_middleware.ts
import type { HttpContext } from '@adonisjs/core/http'
import type { NextFn } from '@adonisjs/core/types/http'

export default class RequestLoggerMiddleware {
  async handle(ctx: HttpContext, next: NextFn) {
    /**
     * Middleware logic goes here (before the next call)
     */
    console.log(ctx)

    /**
     * Call next method in the pipeline and return its output
     */
    const output = await next()
    return output
  }
}</code></pre><p>As you can see, we have access to our HttpContext for the request here, just like in our controller methods. There is also a&nbsp;<code>next()</code>&nbsp;function as well. This next function is what instructs AdonisJS to move on from this middleware to the next one. When the route runs out of middleware, it'll move onto the route handler. Once the route handler is done, it'll work backwards through our middleware, running anything we've defined after the&nbsp;<code>next()</code>&nbsp;function, like a mountain pipeline.</p><pre><code>├── Request
│   ├── Server Middleware (before next)
│   │   ├── Router Middleware (before next)
│   │   │   ├── Route Middleware (before next)
│   │   │   │   └── Route Handler
│   │   │   └── Route Middleware (after next)
│   │   └── Router Middleware (after next)
│   └── Server Middleware (after next)
└── Response</code></pre><p>We can demonstrate this by tracking how long it takes to get from before next to after next!</p><pre><code class="language-ts">// app/middleware/request_logger.ts
import type { HttpContext } from '@adonisjs/core/http'
import type { NextFn } from '@adonisjs/core/types/http'
import { DateTime } from 'luxon'

export default class RequestLoggerMiddleware {
  async handle(ctx: HttpContext, next: NextFn) {
    const start = DateTime.now()

    await next()

    const end = DateTime.now()
    const duration = end.diff(start).as('milliseconds').toFixed(2)

    ctx.logger.info(`[${ctx.request.method()}] ${ctx.request.url()} - ${duration} ms`)
  }
}</code></pre><p>Our HttpContext also has access to a logger for our request. We can use this to print out an info message so that when we visit&nbsp;<code>/challenges</code>&nbsp;in our application, we should see something like:</p><pre><code>INFO (89496): [GET] /challenges - 11.00 ms</code></pre><h2>Middleware Types</h2><p>Since we have this as a server middleware, this will run even if we request a page that doesn't exist. So, if we request&nbsp;<code>/not-found</code>&nbsp;we'll see it still logs out.</p><pre><code>INFO (89496): [GET] /not-found - 29.00 ms</code></pre><p>Let's compare this to router middleware. If we jump into our&nbsp;<code>start/kernel.ts</code>&nbsp;file we'll see the below.</p><pre><code class="language-ts">// start/kernel.ts
/*
|--------------------------------------------------------------------------
| HTTP kernel file
|--------------------------------------------------------------------------
|
| The HTTP kernel file is used to register the middleware with the server
| or the router.
|
*/

import router from '@adonisjs/core/services/router'
import server from '@adonisjs/core/services/server'

/**
 * The error handler is used to convert an exception
 * to an HTTP response.
 */
server.errorHandler(() =&gt; import('#exceptions/handler'))

/**
 * The server middleware stack runs middleware on all the HTTP
 * requests, even if there is no route registered for
 * the request URL.
 */
server.use([
  () =&gt; import('#middleware/container_bindings_middleware'),
  () =&gt; import('@adonisjs/static/static_middleware'),
  () =&gt; import('@adonisjs/vite/vite_middleware'),
  () =&gt; import('#middleware/request_logger_middleware')
])

/**
 * The router middleware stack runs middleware on all the HTTP
 * requests with a registered route.
 */
router.use([
  () =&gt; import('@adonisjs/core/bodyparser_middleware'),
  () =&gt; import('@adonisjs/session/session_middleware'),
  () =&gt; import('@adonisjs/shield/shield_middleware'),
  () =&gt; import('@adonisjs/auth/initialize_auth_middleware'),
  () =&gt; import('#middleware/silent_auth_middleware'),
])

/**
 * Named middleware collection must be explicitly assigned to
 * the routes or the routes group.
 */
export const middleware = router.named({
  guest: () =&gt; import('#middleware/guest_middleware'),
  auth: () =&gt; import('#middleware/auth_middleware'),
})</code></pre><p>We've got our error handling middleware, server middleware, router middleware, and finally named middleware. Lazy-imports are how we register a middleware to a specific middleware type. So, if we move our request logger middleware from&nbsp;<code>server.use</code>&nbsp;to&nbsp;<code>router.use</code>&nbsp;it'll convert from being a server middleware to instead being a router middleware.</p><pre><code class="language-ts">// start/kernel.ts
// ...

/**
 * The server middleware stack runs middleware on all the HTTP
 * requests, even if there is no route registered for
 * the request URL.
 */
server.use([
  () =&gt; import('#middleware/container_bindings_middleware'),
  () =&gt; import('@adonisjs/static/static_middleware'),
  () =&gt; import('@adonisjs/vite/vite_middleware'),
])

/**
 * The router middleware stack runs middleware on all the HTTP
 * requests with a registered route.
 */
router.use([
  () =&gt; import('@adonisjs/core/bodyparser_middleware'),
  () =&gt; import('@adonisjs/session/session_middleware'),
  () =&gt; import('@adonisjs/shield/shield_middleware'),
  () =&gt; import('@adonisjs/auth/initialize_auth_middleware'),
  () =&gt; import('#middleware/silent_auth_middleware'),
++  () =&gt; import('#middleware/request_logger_middleware'),
])

// ...</code></pre><p>If we request&nbsp;<code>/not-found</code>&nbsp;one more time, we'll notice we no longer get a log of our request time. However, if we request&nbsp;<code>/challenges</code>&nbsp;again, our request time log goes through just fine, verifying that server middleware runs for any request and router middleware only runs for registered routes. Note, server and router middleware are executed in the order they're imported here.</p><p>Finally, if we move this from&nbsp;<code>router.use</code>&nbsp;down to our&nbsp;<code>router.named</code>&nbsp;we'll switch this to a named middleware, so we also need to give it a name.</p><pre><code class="language-ts">// start/kernel.ts
// ...

/**
 * The router middleware stack runs middleware on all the HTTP
 * requests with a registered route.
 */
router.use([
  () =&gt; import('@adonisjs/core/bodyparser_middleware'),
  () =&gt; import('@adonisjs/session/session_middleware'),
  () =&gt; import('@adonisjs/shield/shield_middleware'),
  () =&gt; import('@adonisjs/auth/initialize_auth_middleware'),
  () =&gt; import('#middleware/silent_auth_middleware'),
])

/**
 * Named middleware collection must be explicitly assigned to
 * the routes or the routes group.
 */
export const middleware = router.named({
  guest: () =&gt; import('#middleware/guest_middleware'),
  auth: () =&gt; import('#middleware/auth_middleware'),
  requestLogger: () =&gt; import('#middleware/request_logger_middleware'),
})</code></pre><p>If we request&nbsp;<code>/challenges</code>&nbsp;again, note we no longer get a log. This is because named middleware only runs on routes we've specifically added the middleware to. So, we need to first add&nbsp;<code>requestLogger</code>&nbsp;to&nbsp;<code>challenges.index</code>&nbsp;in order for it to log.</p><pre><code class="language-ts">// start/routes.ts
import { controllers } from '#generated/controllers'
import { middleware } from '#start/kernel'
import router from '@adonisjs/core/services/router'

// ...

++router.get('/challenges', [controllers.Challenges, 'index']).use(middleware.requestLogger())
router.get('/challenges/:id', [controllers.Challenges, 'show'])
router.get('/challenges/create', [controllers.Challenges, 'create'])
router.post('/challenges', [controllers.Challenges, 'store'])
router.get('/challenges/:id/edit', [controllers.Challenges, 'edit'])
router.put('/challenges/:id', [controllers.Challenges, 'update'])

// ...</code></pre><p>Request&nbsp;<code>/challenges</code>&nbsp;one more time, and voila! There's our log again.</p><h2>Middleware &amp; Route Groups</h2><p>The&nbsp;<code>use</code>&nbsp;method accepts one or more middleware we want to define on the specific route or route group. Anything we add to route groups gets applied to all routes within the group. So, we could wrap our challenges in a group to easily apply this middleware to all these routes in one go.</p><pre><code class="language-ts">// start/routes.ts
// ...

router
  .group(() =&gt; {
    router.get('/challenges', [controllers.Challenges, 'index'])
    router.get('/challenges/:id', [controllers.Challenges, 'show'])
    router.get('/challenges/create', [controllers.Challenges, 'create'])
    router.post('/challenges', [controllers.Challenges, 'store'])
    router.get('/challenges/:id/edit', [controllers.Challenges, 'edit'])
    router.put('/challenges/:id', [controllers.Challenges, 'update'])
  })
  .use(middleware.requestLogger())

// ...</code></pre><p>We can take this a step further as well and move&nbsp;<code>/challenges</code>&nbsp;to the group-level as well if we wish. This works for names, domains, and matchers as well.</p><pre><code class="language-ts">// start/routes.ts
// ...

router
  .group(() =&gt; {
    router.get('/', [controllers.Challenges, 'index'])
    router.get('/:id', [controllers.Challenges, 'show'])
    router.get('/create', [controllers.Challenges, 'create'])
    router.post('/', [controllers.Challenges, 'store'])
    router.get('/:id/edit', [controllers.Challenges, 'edit'])
    router.put('/:id', [controllers.Challenges, 'update'])
  })
  .prefix('/challenges')
  .use(middleware.requestLogger())

// ...</code></pre><h2>Middleware &amp; Resources</h2><p>Okay, I think we're ready to condense this down to a simple resource route definition. Again, this takes the shared pattern (<code>/challenges</code>) and the controller and AdonisJS will do the rest.</p><pre><code class="language-ts">// start/routes.ts
// ...

router.resource('challenges', controllers.Challenges).use('*', middleware.requestLogger())

// ...</code></pre><p>Note that here&nbsp;<code>use</code>&nbsp;requires two arguments. In the first, we specify which route in the resource to target,&nbsp;<code>*</code>&nbsp;will target them all. Then, we provide the middleware. The first argument can also be an array of routes as well.</p><pre><code class="language-ts">// start/routes.ts
// ...

router
  .resource('challenges', controllers.Challenges)
  .use(['index', 'edit', 'create'], middleware.requestLogger())

// ...</code></pre><h2>Prematurely Ending A Request</h2><p>Finally, we can prematurely stop a request in its tracks with middleware as well by returning a response before the&nbsp;<code>next</code>&nbsp;method.</p><pre><code class="language-ts">// app/middleware/request_logger_middleware.ts
import type { HttpContext } from '@adonisjs/core/http'
import type { NextFn } from '@adonisjs/core/types/http'
import { DateTime } from 'luxon'

export default class RequestLoggerMiddleware {
  async handle(ctx: HttpContext, next: NextFn) {
++    if (ctx.request.input('bail')) {
++      return ctx.response.json({ message: 'Bailed out!' })
++    }

    const start = DateTime.now()

    await next()

    const end = DateTime.now()
    const duration = end.diff(start).as('milliseconds').toFixed(2)

    ctx.logger.info(`[${ctx.request.method()}] ${ctx.request.url()} - ${duration} ms`)
  }
}</code></pre><p>the&nbsp;<code>input</code>&nbsp;method on our request will attempt to find the key provided in either our request body or query string. So, if we request&nbsp;<code>/challenges?bail=true</code>&nbsp;then we'll get our "Bailed out!" message. If we omit the&nbsp;<code>bail</code>&nbsp;query param, our request will go on as usual!</p>]]></content:encoded>
      </item>
      <item>
        <title><![CDATA[Sessions & Flashing Messages]]></title>
        <link>https://adocasts.com/lessons/sessions-and-flashing-messages</link>
        <guid>https://adocasts.com/lessons/sessions-and-flashing-messages</guid>
        <pubDate>Fri, 10 Apr 2026 11:00:00 +0000</pubDate>
        <description><![CDATA[Learn HTTP sessions in AdonisJS for maintaining user state across requests. We'll also learn about flash messaging and the basics of working with it.]]></description>
        <content:encoded><![CDATA[<p><a target="_blank" rel="nofollow noopener noreferrer" href="https://docs.adonisjs.com/guides/basics/session">Sessions</a> are a temporary data storage mechanism that allows your application to maintain state across multiple requests for a specific user. They're essential for features like authentication, user preferences, and even shopping carts.</p><p>When a user first visits your site, AdonisJS creates a session for them and stores a session ID in a cookie. On subsequent requests, the browser sends this cookie, allowing AdonisJS to retrieve the session data associated with that user.</p><pre><code>Request 1 → Create Session → Set Cookie → Response
Request 2 → Read Cookie → Retrieve Session → Response
Request 3 → Read Cookie → Retrieve Session → Response</code></pre><h2>Setting Setting State</h2><p>Sessions are ephemeral by default; they exist for the duration of the user's visit and can be cleared when the browser closes (depending on configuration).</p><p>You can set data on a session using the&nbsp;<code>session.put()</code>&nbsp;method within your controller:</p><pre><code class="language-ts">// app/controllers/challenges_controller.ts
import type { HttpContext } from '@adonisjs/core/http'

export default class ChallengesController {
  // ...
  
  async store({ request, response, session }: HttpContext) {
    const data = await request.validateUsing(challengeValidator)

    challenges.push({ id: challenges.length + 1, ...data })

++    session.put('success', 'Challenge created successfully')

    return response.redirect().toRoute('challenges.index')
  }

  //...
}</code></pre><p>The&nbsp;<code>session.put()</code>&nbsp;method takes a key and a value. You can store primitives like strings and numbers, objects, or even arrays.</p><h2>Accessing Session State</h2><p>Session data can then be accessed throughout your request and views via the <a target="_blank" rel="nofollow noopener noreferrer" href="https://docs.adonisjs.com/guides/basics/http-context">HttpContext</a>. Within our controllers and middleware, we can use&nbsp;<code>session.get()</code>.</p><pre><code class="language-ts">// app/controllers/challenges_controller.ts
async index({ session, view }: HttpContext) {
  const successMessage = session.get('success')
  
  return view.render('pages/challenges/index', { 
    challenges,
    successMessage 
  })
}</code></pre><p>We don't need to access our session within controllers though,&nbsp;<code>session</code>&nbsp;is also made directly available as an EdgeJS global, allowing easy access to a similar API within our views. One key note, though, is that the&nbsp;<code>session</code>&nbsp;in our views is a read-only store. Meaning, we can't alter the store with it.</p><pre><code class="language-edge">// resources/views/pages/challenges/index.edge
@layout()

  &lt;div&gt;
++    @if (session.has('success'))
++      &lt;div class="alert alert-success"&gt;
++        {{ session.get('success') }}
++      &lt;/div&gt;
++    @endif
    
    &lt;div class="hero"&gt;
      &lt;h1&gt;Challenges&lt;/h1&gt;
  
      @include('partials/challenge/available_points')
  
      &lt;a href="{{ route('challenges.create') }}" class="button"&gt;Create a new challenge&lt;/a&gt;
    &lt;/div&gt;

    @challenge.grid() 
      @each(challenge in challenges)
        @!challenge.gridItem({ challenge }) 
      @endeach
    @end
  &lt;/div&gt;

@end</code></pre><p>Now, there's an issue with what we just did. If we refresh our page, our session is still signalling that we've just created a challenge because the message is persisted in our session for the duration of our session or until we instruct our session to forget it.</p><h2>What Are Flash Messages?</h2><p><a target="_blank" rel="nofollow noopener noreferrer" href="https://docs.adonisjs.com/guides/basics/session#flash-messages">Flash messages</a> are a special type of session data in that they only last for a single request. This makes them perfect for one-time notifications like what we have above. Unlike&nbsp;<code>session.put()</code>, where the data persists until you manually delete it, when we use&nbsp;<code>session.flash()</code>, the data is automatically forgotten on the next request.</p><pre><code class="language-ts">// app/controllers/challenges_controller.ts
import type { HttpContext } from '@adonisjs/core/http'

export default class ChallengesController {
  // ...
  
  async store({ request, response, session }: HttpContext) {
    const data = await request.validateUsing(challengeValidator)

    challenges.push({ id: challenges.length + 1, ...data })

++    session.flash('success', 'Challenge created successfully')

    return response.redirect().toRoute('challenges.index')
  }

  //...
}</code></pre><p>If you'll recall back to our lesson on validation, flash messaging is how request validations provide errors and old form data to us. In that, we learned we can access flash messages using&nbsp;<code>flashMessages.get('success')</code>, which would return our message. Since we're familiar, let's jump straight in because the starter kit is already set up to show our success flash message!</p><p>Let's remind ourselves of our layout:</p><pre><code class="language-edge">// resources/views/components/layout.edge
&lt;!DOCTYPE html&gt;
&lt;html lang="en-us"&gt;
  &lt;head&gt;
    &lt;meta charset="utf-8" /&gt;
    &lt;meta name="viewport" content="width=device-width, initial-scale=1" /&gt;
    &lt;title&gt;
      AdonisJS - A fully featured web framework for Node.js
    &lt;/title&gt;
    @vite(['resources/css/app.css', 'resources/js/app.js'])
    @stack('dumper')
  &lt;/head&gt;
  &lt;body&gt;
    @include('partials/header')
    &lt;main&gt;
++      @include('partials/flash_alerts')
      {{{ await $slots.main() }}}
    &lt;/main&gt;
  &lt;/body&gt;
&lt;/html&gt;</code></pre><p>Note the&nbsp;<code>flash_alerts</code>&nbsp;partial! If we check out this partial next:</p><pre><code class="language-edge">// resources/views/partials/flash_alerts.edge
&lt;div class="flash-container"&gt;
  @if(flashMessages.has('error'))
    @alert.root({ variant: 'destructive', autoDismiss: true })
      @!alert.description({ text: flashMessages.get('error') })
    @end
  @end
  @if(flashMessages.has('success'))
    @alert.root({ variant: 'success', autoDismiss: true })
      @!alert.description({ text: flashMessages.get('success') })
    @end
  @end
&lt;/div&gt;</code></pre><p>We'll see that it's checking to see if our flash message store has an "error" or "success" value. If so, it's using alert components to render out the flash message on our page! So, let's check it out and give our page a refresh.</p><p>Hmm... nothing changed. Ah! Because we never instructed our session to forget our previous success value.</p><h2>Forgetting Session State</h2><p>Forgetting state in our session is pretty straightforward; we just need to call&nbsp;<code>session.forget()</code>&nbsp;from our HttpContext (remember it's read-only in our views) and provide the key we want to forget. We could also use&nbsp;<code>session.clear()</code>&nbsp;to remove all data from our session.</p><pre><code class="language-ts">// app/controllers/challenges_controller.ts
export default class ChallengesController {
  async index({ session, view }: HttpContext) {
++    session.forget('success')
    
    return view.render('pages/challenges/index', { challenges })
  }
}</code></pre><p>Now, if we refresh once more, our success message should be gone. Okay, we don't need that anymore, let's remove it.</p><pre><code class="language-ts">// app/controllers/challenges_controller.ts
export default class ChallengesController {
  async index({ view }: HttpContext) {
    return view.render('pages/challenges/index', { challenges })
  }
}</code></pre><p>Great, let's create a new challenge really quickly. When we do, we should see our success flash message display. However, this time, if we refresh, our flash message should be gone and won't display again until another challenge is created. Perfect!</p><p>Small note, you may find yourself in a situation where you need to keep your flash messages for one more request. This can be easily done using&nbsp;<code>session.reflash()</code>. There's also&nbsp;<code>session.reflashOnly()</code>&nbsp;and&nbsp;<code>session.reflashExcept()</code>&nbsp;to include or omit specific keys.</p><pre><code class="language-ts">// app/controllers/challenges_controller.ts
export default class ChallengesController {
  async index({ session, view }: HttpContext) {
++    session.reflash()

    return view.render('pages/challenges/index', { challenges })
  }
}</code></pre><p>With this added, now if we create a new challenge, we'll get redirected to our index page. Our index page will reflash it's flash messages, meaning they'll be kept for one more request. So, if we refresh our page, we'll see our success message yet again. This is particularly handy when handling errors manually!</p>]]></content:encoded>
      </item>
      <item>
        <title><![CDATA[Validation & Flash Storage]]></title>
        <link>https://adocasts.com/lessons/validation-and-flash-storage</link>
        <guid>https://adocasts.com/lessons/validation-and-flash-storage</guid>
        <pubDate>Thu, 26 Mar 2026 11:00:00 +0000</pubDate>
        <description><![CDATA[Learn how to validate user-provided data and forms in AdonisJS using VineJS. We'll also discuss how to validate route parameters, query strings, cookies, and headers as well.]]></description>
        <content:encoded><![CDATA[<p>As mentioned in the last lesson, we want to be defensive with all the user-provided data we accept in our applications. The best way to do that is with <a target="_blank" rel="nofollow noopener noreferrer" href="https://docs.adonisjs.com/guides/basics/validation">validation</a>, which allows us to ensure that the data provided adheres to the constraints we've defined.</p><h2>Defining Validators</h2><p>To start, we'll make a new file for our challenge validator using the Ace CLI.</p><pre><code class="language-bash">node ace make:validator challenge
# DONE:    create app/validators/challenge.ts</code></pre><p>We can find our validator files within&nbsp;<code>app/validators</code>&nbsp;and a single file can define and export multiple validators. When we open our new challenge validator file, we'll find an import for VineJS. <a target="_blank" rel="nofollow noopener noreferrer" href="https://vinejs.dev/docs/introduction">VineJS</a> is AdonisJS's validation system; like EdgeJS, it too is home-grown by the AdonisJS Core Team.</p><pre><code class="language-ts">// app/validators/challenge.ts
import vine from '@vinejs/vine'</code></pre><p>We define a validator by calling the&nbsp;<code>create</code>&nbsp;method from&nbsp;<code>vine</code>&nbsp;and pass in an object where the keys are the field names we're submitting with our form. The values of our object are then the validation rules for that field.</p><pre><code class="language-ts">// app/validators/challenge.ts
import vine from '@vinejs/vine'

++export const challengeValidator = vine.create({
++  text: vine.string().minLength(5).maxLength(255),
++  points: vine.number().min(1).max(100),
++})</code></pre><p>We start by describing the type of the field, our text is a string, and points is a number. Then, we can add additional constraints or normalizations we want the field to adhere to. Here, we're saying the text value must be between 5 and 255 characters long, and the points value must be between 1 and 100. Alternatively, we could use&nbsp;<code>range([1, 100])</code>&nbsp;to do the same.</p><p>By default, any field we define in our validator will be required, unless specifically marked as&nbsp;<code>optional()</code>. In our case, we need both of these fields for our challenge, so we'll keep them both as required.</p><h2>Validating Data</h2><p>Next, we need to use our validator within our controller.</p><pre><code class="language-ts">// app/controllers/challenges_controller.ts
import type { HttpContext } from '@adonisjs/core/http'

const challenges = [
  { id: 1, text: 'Learn AdonisJS', points: 10 },
  { id: 2, text: 'Learn EdgeJS', points: 5 },
  { id: 3, text: 'Build an AdonisJS app', points: 20 },
]

export default class ChallengesController {
  // ...

  /**
   * Handle form submission for the create action
   */
  async store({ request, response }: HttpContext) {
++    const data = await request.validateUsing(challengeValidator)

    challenges.push({ id: challenges.length + 1, ...data })

    return response.redirect('/challenges')
  }

  // ...

  /**
   * Handle form submission for the edit action
   */
  async update({ params, request, response }: HttpContext) {
++    const data = await request.validateUsing(challengeValidator)

    // find the challenge being updated by its id, and update its data
    const challengeIndex = challenges.findIndex((row) =&gt; row.id === params.id)
    challenges[challengeIndex] = { id: params.id, ...data }

    return response.redirect('/challenges')
  }

  // ...
}</code></pre><p>By using the validator directly off our request, AdonisJS will automatically pass our request's data, including the body, route params, query strings, cookies, and headers, to the validator. The request body is a direct property, while everything else is nested under their applicable name, for example:</p><pre><code class="language-ts">// example
import vine from '@vinejs/vine'

export const exampleValidator = vine.create({
  someBodyProperty: vine.string(),

  params: vine.object({
    id: vine.number()
  }),

  qs: vine.object({
    sort: vine.string().in(['asc', 'desc']),
  }),

  cookies: vine.object({
    sessionId: vine.string()
  }),

  headers: vine.object({
    'x-api-version': vine.number().range([1, 7])
  })
})</code></pre><p>Additionally, the data we get back from our validation is type-safe as well. If, for any reason, we need to use a validator's type, we can easily do so with VineJS's&nbsp;<code>Infer</code>&nbsp;type helper.</p><pre><code class="language-ts">import { type Infer } from '@vinejs/vine/types'

function example({ text, points }: Infer&lt;typeof challengeValidator&gt;) {
  console.log({ text, points })
}</code></pre><p>Our&nbsp;<code>challengeValidator</code>&nbsp;will give us our object type:</p><pre><code class="language-ts">const data: {
  text: string;
  points: number;
}</code></pre><p>Finally, if we need to explicitly define the data that should be validated, we can do that as well using the validator directly.</p><pre><code class="language-ts">// app/controllers/challenges_controller.ts
export default class ChallengesController {
  // ...

  /**
   * Handle form submission for the create action
   */
  async store({ request, response }: HttpContext) {
++    const data = await challengeValidator.validate(request.all())

    challenges.push({ id: challenges.length + 1, ...data })

    return response.redirect('/challenges')
  }

  // ...
}</code></pre><h2>Validation Errors &amp; User Feedback</h2><p>Okay, so let's give our validator a try by entering something like:</p><pre><code class="language-json">{
  "text": "test",
  "points": 500
}</code></pre><p>When we send this, you'll notice we get redirected right back to where we were, and our form is emptied, why? When our validation fails, the validator will throw an exception. Our exception handler will then conveniently handle this for us using content-negotiation. When we submit an HTML form, it will redirect us back to the page. When using&nbsp;<code>application/json</code>&nbsp;it will return a 422 status code with our errors. So, where can we find our errors for form submissions?</p><p>Let's dump our state, and fill out our form again with a text of "test" and "500" points, and submit again.</p><pre><code class="language-edge">// resources/views/pages/challenges/create.edge
@layout()

  &lt;div class="form-container"&gt;
    &lt;div&gt;
      &lt;h1&gt; Create Challenge &lt;/h1&gt;
      &lt;p&gt; Enter your challenge details below &lt;/p&gt;
    &lt;/div&gt;

++    @dump(state)

    {{-- ... --}}
  &lt;/div&gt;

@end</code></pre><p>On our&nbsp;<code>state</code>&nbsp;we should see a property called&nbsp;<code>flashMessages</code>. Flash messages are messages sent by our server for this single request. If we expand this, we should see the following.</p><pre><code class="language-ts">flashMessages: ReadOnlyValuesStore {
  values: Object {
    text: 'test',
    points: '500',
    errorsBag: Object {
      E_VALIDATION_ERROR: 'The form could not be saved. Please check the errors below.',
    },
    inputErrorsBag: Object {
      text: Array:1 [
        'The text field must have at least 5 characters',
      ],
      points: Array:1 [
        'The points field must be between 1 and 100',
      ],
    },
  },
  [[Prototype]] {}
}</code></pre><p>This is where we can find the contextual information about our validation failure. The&nbsp;<code>errorsBag</code>&nbsp;is where we'll find general exceptions and errors and&nbsp;<code>inputErrorsBag</code>&nbsp;is where we'll find specific validation errors. If there is a property in here, it means that the field failed validation, and AdonisJS gives an array of messages for those failures. Additionally, you'll also note AdonisJS has flashed the text and point values we submitted as well.</p><p>So, how can we use all of this? Well, for starters, we can access them directly with our&nbsp;<code>flashMessages</code>&nbsp;directly, or using one of its helper methods listed in its prototype.</p><pre><code class="language-ts">flashMessages: ReadOnlyValuesStore {
  values: Object {...},
  [[Prototype]] {
    isEmpty: [Getter]
    get: [function get],
    has: [function has],
    all: [function all],
    toObject: [function toObject],
    toJSON: [function toJSON],
  }
}</code></pre><pre><code class="language-edge">// resources/views/pages/challenges/create.edge
@layout()

  &lt;div class="form-container"&gt;
    &lt;div&gt;
      &lt;h1&gt; Create Challenge &lt;/h1&gt;
      &lt;p&gt; Enter your challenge details below &lt;/p&gt;
    &lt;/div&gt;

    @dump(state)

    &lt;div&gt;
      &lt;form action="/challenges" method="POST"&gt;
        {{ csrfField() }}

        &lt;div&gt;
          &lt;label&gt;
            Text
            &lt;input type="text" name="text" /&gt;
          &lt;/label&gt;

++          @if (flashMessages.has('inputErrorsBag.text'))
++            &lt;div&gt;
++              {{ flashMessages.get('inputErrorsBag.text').join(', ') }}
++            &lt;/div&gt;
++          @endif
        &lt;/div&gt;

        &lt;div&gt;
          &lt;label&gt;
            Points
            &lt;input type="number" name="points" /&gt;
          &lt;/label&gt;

++          @if (flashMessages.has('inputErrorsBag.points'))
++            &lt;div&gt;
++              {{ flashMessages.get('inputErrorsBag.points').join(', ') }}
++            &lt;/div&gt;
++          @endif
        &lt;/div&gt;

        &lt;div&gt;
          &lt;button type="submit" class="button"&gt; Create Challenge &lt;/button&gt;
        &lt;/div&gt;
      &lt;/form&gt;
    &lt;/div&gt;
  &lt;/div&gt;

@end</code></pre><p>This is a common situation, though, so AdonisJS has added some utility tags to help us get at this data called&nbsp;<code>@inputError</code>. This tag injects our errors into the main scope, so we can access them with just&nbsp;<code>$messages</code>.</p><pre><code class="language-edge">// resources/views/pages/challenges/create.edge
@layout()

  &lt;div class="form-container"&gt;
    &lt;div&gt;
      &lt;h1&gt; Create Challenge &lt;/h1&gt;
      &lt;p&gt; Enter your challenge details below &lt;/p&gt;
    &lt;/div&gt;

    @dump(state)

    &lt;div&gt;
      &lt;form action="/challenges" method="POST"&gt;
        {{ csrfField() }}

        &lt;div&gt;
          &lt;label&gt;
            Text
            &lt;input type="text" name="text" /&gt;
          &lt;/label&gt;

++          @inputError('text')
++            &lt;div&gt;
++              {{ $messages.join(', ') }}
++            &lt;/div&gt;
++          @end
        &lt;/div&gt;

        &lt;div&gt;
          &lt;label&gt;
            Points
            &lt;input type="number" name="points" /&gt;
          &lt;/label&gt;

++          @inputError('points')
++            &lt;div&gt;
++              {{ $messages.join(', ') }}
++            &lt;/div&gt;
++          @end
        &lt;/div&gt;

        &lt;div&gt;
          &lt;button type="submit" class="button"&gt; Create Challenge &lt;/button&gt;
        &lt;/div&gt;
      &lt;/form&gt;
    &lt;/div&gt;
  &lt;/div&gt;

@end</code></pre><p>Fantastic! Same situation if we'd like to repopulate our form with its previously submitted values, which is generally a good idea. Again, this is a common situation, so there's a utility method called&nbsp;<code>old()</code>&nbsp;to help us get at this as well.</p><pre><code class="language-edge">// resources/views/pages/challenges/create.edge
@layout()

  &lt;div class="form-container"&gt;
    &lt;div&gt;
      &lt;h1&gt; Create Challenge &lt;/h1&gt;
      &lt;p&gt; Enter your challenge details below &lt;/p&gt;
    &lt;/div&gt;

    @dump(state)

    &lt;div&gt;
      &lt;form action="/challenges" method="POST"&gt;
        {{ csrfField() }}

        &lt;div&gt;
          &lt;label&gt;
            Text
++            &lt;input type="text" name="text" value="{{ flashMessages.get('text', '') }}" /&gt;
          &lt;/label&gt;

          @inputError('text')
            &lt;div&gt;
              {{ $messages.join(', ') }}
            &lt;/div&gt;
          @end
        &lt;/div&gt;

        &lt;div&gt;
          &lt;label&gt;
            Points
++            &lt;input type="number" name="points" value="{{ old('points') }}" /&gt;
          &lt;/label&gt;

          @inputError('points')
            &lt;div&gt;
              {{ $messages.join(', ') }}
            &lt;/div&gt;
          @end
        &lt;/div&gt;

        &lt;div&gt;
          &lt;button type="submit" class="button"&gt; Create Challenge &lt;/button&gt;
        &lt;/div&gt;
      &lt;/form&gt;
    &lt;/div&gt;
  &lt;/div&gt;

@end</code></pre><h2>Cleaning Up with Components</h2><p>Great, our form is looking good! Let's make it look better, because the starter kit comes with components, wrapping everything we've done here in a pretty little bow!</p><pre><code class="language-edge">// resources/views/pages/challenges/create.edge
@layout()

  &lt;div class="form-container"&gt;
    &lt;div&gt;
      &lt;h1&gt; Create Challenge &lt;/h1&gt;
      &lt;p&gt; Enter your challenge details below &lt;/p&gt;
    &lt;/div&gt;

    &lt;div&gt;
      &lt;form action="/challenges" method="POST"&gt;
        {{ csrfField() }}

        &lt;div&gt;
++          @field.root({ name: 'text' })
++            @!field.label({ text: 'Text' })
++            @!input.control({ type: 'text' })
++            @!field.error()
++          @end
        &lt;/div&gt;

        &lt;div&gt;
++          @field.root({ name: 'points' })
++            @!field.label({ text: 'Points' })
++            @!input.control({ type: 'number' })
++            @!field.error()
++          @end
        &lt;/div&gt;

        &lt;div&gt;
          &lt;button type="submit" class="button"&gt; Create Challenge &lt;/button&gt;
        &lt;/div&gt;
      &lt;/form&gt;
    &lt;/div&gt;
  &lt;/div&gt;

@end</code></pre><p>Here,&nbsp;<code>@field.root({ name: 'text' })</code>&nbsp;injects the previously submitted value and validation errors into its child context. The control and error then read that context to display things similarly to how we just had it ourselves, giving you an idea of just how powerful components can be at condensing things into an easily digestible package.</p><p>The only thing these components use that we haven't already covered are prop helpers! If we, for example, take a look at our field label component.</p><pre><code class="language-edge">// resources/views/components/field/label.edge
@let(labelTextFromSlot = await $slots.main())
@let(labelText = labelTextFromSlot.trim() ? labelTextFromSlot : $props.get('text'))
@let(classes = [])

&lt;label {{ $props
  .except(['text'])
  .merge({
    for: $context.id,
    class: classes,
    'data-invalid': $context.hasErrors ? 'true' : false,
  })
  .toAttrs() }}&gt;{{{ labelText }}}&lt;/label&gt;</code></pre><p>First, it's putting its slot's HTML into a variable called&nbsp;<code>labelTextFromSlot</code>. If it has contents, it'll use that as the value for&nbsp;<code>labelText</code>, otherwise it will try to get the&nbsp;<code>text</code>&nbsp;prop using the&nbsp;<code>$props</code>&nbsp;get method utility.</p><p>We've seen these methods before when we took a look at&nbsp;<code>$props</code>&nbsp;prototype methods:</p><pre><code class="language-ts">ComponentProps {
  [[Prototype]] {
    all: [function all],
    has: [function has],
    get: [function get],
    only: [function only],
    except: [function except],
    merge: [function merge],
    mergeIf: [function mergeIf],
    mergeUnless: [function mergeUnless],
    toAttrs: [function toAttrs],
  }
}</code></pre><p>Our label is stating that it wants to use all the props except&nbsp;<code>text</code>. Merge&nbsp;<code>for</code>,&nbsp;<code>class</code>, and&nbsp;<code>data-invalid</code>&nbsp;into those props. Finally,&nbsp;<code>toAttrs()</code>&nbsp;takes the chain's results and converts them into valid HTML attributes to be placed on the label.</p><p>Sweet, before we round out this lesson, let's get our edit page up-to-date as well.</p><pre><code class="language-edge">// resources/views/pages/challenges/edit.edge
@layout()

  &lt;div class="form-container"&gt;
    &lt;div&gt;
      &lt;h1&gt; Edit Challenge &lt;/h1&gt;
      &lt;p&gt; Enter your challenge details below &lt;/p&gt;
    &lt;/div&gt;

    &lt;div&gt;
      &lt;form action="/challenges/{{ challenge.id }}?_method=PUT" method="POST"&gt;
        {{ csrfField() }}

        &lt;div&gt;
          @field.root({ name: 'text' })
            @!field.label({ text: 'Text' })
            @!input.control({ type: 'text', value: challenge.text })
            @!field.error()
          @end
        &lt;/div&gt;

        &lt;div&gt;
          @field.root({ name: 'points' })
            @!field.label({ text: 'Points' })
            @!input.control({ type: 'number', value: challenge.points })
            @!field.error()
          @end
        &lt;/div&gt;

        &lt;div&gt;
          &lt;button type="submit" class="button"&gt; Update Challenge &lt;/button&gt;
        &lt;/div&gt;
      &lt;/form&gt;
    &lt;/div&gt;
  &lt;/div&gt;

@end</code></pre><p>Remember, we need to pre-populate the field's values with the values of the challenge we're editing. Like our label component, the input control component will also build out and merge its props into attributes, so&nbsp;<code>value</code>&nbsp;works just fine here!</p>]]></content:encoded>
      </item>
      <item>
        <title><![CDATA[Components, Layouts, & Partials]]></title>
        <link>https://adocasts.com/lessons/components-layouts-and-partials</link>
        <guid>https://adocasts.com/lessons/components-layouts-and-partials</guid>
        <pubDate>Thu, 26 Mar 2026 11:00:00 +0000</pubDate>
        <description><![CDATA[Learn all about EdgeJS components, layouts, and partials for reusable view markup. We'll discuss the differences between the three and dig into specifics on how to use components and their many features]]></description>
        <content:encoded><![CDATA[<p>In EdgeJS, there are two options for reusable markup partials and components.</p><ul><li><p><a target="_blank" rel="nofollow noopener noreferrer" href="https://edgejs.dev/docs/partials">Partials</a>, reusable fragments that share the same state as their parent</p></li><li><p><a target="_blank" rel="nofollow noopener noreferrer" href="https://edgejs.dev/docs/components/introduction">Components</a>, reusable fragments with isolated state, props, and slots</p></li></ul><h2>Partials</h2><p>Partials are straightforward, reusable bits of markup that share the same state as their parent. They behave as though the markup written inside them was written directly in the parent, just with the bonus of reusability.</p><p>They can, however, also be used to keep things clean as well. So, let's add a partial to extract our&nbsp;<code>availablePoints</code>&nbsp;aggregation out of our page to clean this up a little bit.</p><pre><code class="language-bash">node ace make:view partials/challenge/available_points
# DONE:    create resources/views/partials/challenge/available_points.edge</code></pre><pre><code class="language-edge">// resources/views/partials/challenge/available_points.edge
@let(availablePoints = challenges.reduce((total, challenge) =&gt; total + challenge.points, 0))

@if (completedChallenges?.length)
  @let(completedPoints = completedChallenges.reduce((total, challenge) =&gt; total + challenge.points, 0))
  @assign(availablePoints = availablePoints - completedPoints)
@endif

&lt;div&gt;
  Total Points Available: {{ availablePoints }}
&lt;/div&gt;</code></pre><p>Since this shares the same state as its parent, we can openly use our&nbsp;<code>challenges</code>&nbsp;state available on our page. Next, we need to include this partial in our page. For that, there's a special&nbsp;<code>@include</code>&nbsp;tag that accepts the path to our desired partial. Again, AdonisJS already knows to look within&nbsp;<code>resources/views</code>&nbsp;so that should be omitted. Additionally, the&nbsp;<code>@partial</code>&nbsp;tag is self-closing, meaning we do not need to&nbsp;<code>@end</code>&nbsp;it.</p><pre><code class="language-edge">// resources/views/pages/challenges/index.edge
@layout()

  &lt;div&gt;
    &lt;h1&gt;Challenges&lt;/h1&gt;
    &lt;p&gt;Welcome to the challenges page.&lt;/p&gt;

++    @include('partials/challenge/available_points')

    @if (!challenges.length)
      &lt;p&gt;No challenges available.&lt;/p&gt;
    @else
      &lt;p&gt;Below are our available challenges:&lt;/p&gt;
    @endif

    &lt;ul&gt;
      @each(challenge in challenges)
        &lt;li&gt;
          &lt;a href="/challenges/{{ challenge.id }}"&gt;
            &lt;h3&gt;{{ challenge.text }}&lt;/h3&gt;
            &lt;p&gt;Points: {{ challenge.points }}&lt;/p&gt;
          &lt;/a&gt;
        &lt;/li&gt;
      @endeach
    &lt;/ul&gt;
  &lt;/div&gt;

@end</code></pre><p>With that, our page should render the same as before, with the bonus of cleanliness in our page!</p><h2>Components</h2><p>Now, unlike partials, components don't share state, but instead have their own isolated state. Meaning, if we wanted to access&nbsp;<code>challenges</code>&nbsp;from our page, we would need to pass it into the component via what's called props. The props we pass into the component are then merged into the isolated state of the component and made available for use. Components do, however, still have access to global and local state!</p><p>For this, let's focus on our unordered list by creating a new&nbsp;<code>challenge_grid</code>&nbsp;component.</p><pre><code class="language-bash">node ace make:view components/challenge/grid
# DONE:    create resources/views/components/challenge/grid.edge</code></pre><p>Next, let's move our unordered list into this component.</p><pre><code class="language-edge">// resources/views/components/challenge/grid.edge
&lt;ul&gt;
  @each(challenge in challenges)
    &lt;li&gt;
      &lt;a href="/challenges/{{ challenge.id }}"&gt;
        &lt;h3&gt;{{ challenge.text }}&lt;/h3&gt;
        &lt;p&gt;Points: {{ challenge.points }}&lt;/p&gt;
      &lt;/a&gt;
    &lt;/li&gt;
  @endeach
&lt;/ul&gt;</code></pre><p>Components can be used similarly to partials via a designated&nbsp;<code>@component()</code>&nbsp;tag. This also takes a path to the component from&nbsp;<code>resources/views</code>.</p><pre><code class="language-edge">// resources/views/pages/challenges/index.edge
@layout()

  &lt;div&gt;
    &lt;h1&gt;Challenges&lt;/h1&gt;
    &lt;p&gt;Welcome to the challenges page.&lt;/p&gt;

    @include('partials/challenge/available_points')

    @if (!challenges.length)
      &lt;p&gt;No challenges available.&lt;/p&gt;
    @else
      &lt;p&gt;Below are our available challenges:&lt;/p&gt;
    @endif

++    @component('components/challenge/grid')
++    @endcomponent
  &lt;/div&gt;

@end</code></pre><p>Note, unlike partials, components must be ended. More on the reason why in a minute.</p><h2>Component Props</h2><p>Since components have an isolated state, we have to pass the data from our page that we want it to have access to as a prop, which can be done via an object as the second argument.</p><pre><code class="language-edge">// resources/views/pages/challenges/index.edge
@layout()

  &lt;div&gt;
    &lt;h1&gt;Challenges&lt;/h1&gt;
    &lt;p&gt;Welcome to the challenges page.&lt;/p&gt;

    @include('partials/challenge/available_points')

    @if (!challenges.length)
      &lt;p&gt;No challenges available.&lt;/p&gt;
    @else
      &lt;p&gt;Below are our available challenges:&lt;/p&gt;
    @endif

++    @component('components/challenge/grid', { challenges })
    @endcomponent
  &lt;/div&gt;

@end</code></pre><p>With that, our component now has access to&nbsp;<code>challenges</code>&nbsp;from the render state of our page and can successfully render out our unordered list! Now, although our component props are merged into the state, we can directly access props as well via&nbsp;<code>$props</code>&nbsp;should we need to. Let's dump this out and take a look!</p><pre><code class="language-edge">// resources/views/components/challenge/grid.edge
&lt;ul&gt;
  @each(challenge in challenges)
    &lt;li&gt;
      &lt;a href="/challenge/{{ challenge .id }}"&gt;
        &lt;h3&gt;{{ challenge.text }}&lt;/h3&gt;
        &lt;p&gt;Points: {{ challenge.points }}&lt;/p&gt;
      &lt;/a&gt;
    &lt;/li&gt;
  @endeach
&lt;/ul&gt;

++@dump($props)</code></pre><p>From our dump, we can see this is a&nbsp;<code>ComponentProps</code>&nbsp;object-like structure. Our&nbsp;<code>challenges</code>&nbsp;is a key on this with our array within it, but if we dig into the prototype, we can see this has some utility methods on it as well to get all props, specific props, and more.</p><pre><code class="language-ts">ComponentProps {
  challenges: Array:3 [],
  [[Prototype]] {
    all: [function all],
    has: [function has],
    get: [function get],
    only: [function only],
    except: [function except],
    merge: [function merge],
    mergeIf: [function mergeIf],
    mergeUnless: [function mergeUnless],
    toAttrs: [function toAttrs],
  }
}</code></pre><p>Okay, we can remove that dump, and let's alter our markup here slightly, removing our unordered list and adding a&nbsp;<code>cards</code>&nbsp;class that came with the starter kit. This will display our challenges in a grid with three columns.</p><pre><code class="language-edge">// resources/views/components/challenge/grid.edge
&lt;div class="cards"&gt;
  @each(challenge in challenges)
    &lt;a href="/challenges/{{ challenge.id }}"&gt;
      &lt;h3&gt;{{ challenge.text }}&lt;/h3&gt;
      &lt;p&gt;Points: {{ challenge.points }}&lt;/p&gt;
    &lt;/a&gt;
  @endeach
&lt;/div&gt;</code></pre><p>Next, let's add another component that will be in charge of rendering each challenge within our grid.</p><pre><code class="language-bash">node ace make:view components/challenge/grid_item
# DONE:    create resources/views/components/challenge/grid_item.edge</code></pre><p>We'll move the div from inside our loop into this new component.</p><pre><code class="language-edge">// resources/views/components/challenge/grid_item.edge
&lt;a href="/challenges/{{ challenge.id }}"&gt;
  &lt;h3&gt;{{ challenge.text }}&lt;/h3&gt;
  &lt;p&gt;Points: {{ challenge.points }}&lt;/p&gt;
&lt;/a&gt;</code></pre><p>Then, let's add this component within our loop and pass each challenge into it as a prop.</p><pre><code class="language-edge">// resources/views/components/challenge/grid.edge
&lt;div class="cards"&gt;
  @each(challenge in challenges)
++    @component('components/challenge/grid_item', { challenge })
++    @endcomponent
  @endeach
&lt;/div&gt;</code></pre><h2>Component Slots</h2><p>A couple of things here. Although the component tag is not self-closing, we can mark it as such by using an exclamation point, like&nbsp;<code>@!component()</code>. With this, we no longer need an&nbsp;<code>@end</code>, so we can remove it.</p><pre><code class="language-edge">// resources/views/components/challenge/grid.edge
&lt;div class="cards"&gt;
  @each(challenge in challenges)
++    @!component('components/challenge/grid_item', { challenge })
  @endeach
&lt;/div&gt;</code></pre><p>Why aren't components self-closing? Because they can utilize slots or child content. We can register a position inside our component for where child contents should go!</p><pre><code class="language-edge">// resources/views/components/challenge/grid.edge
&lt;div class="cards"&gt;
  @each(challenge in challenges)
++    {{{ await $slots.main() }}}
  @endeach
&lt;/div&gt;</code></pre><p>Here,&nbsp;<code>$slots</code>&nbsp;is an object that gets named slots we can utilize within our component. The&nbsp;<code>main</code>&nbsp;slot is the default slot and will always be defined. If this looks familiar, it's because we saw exactly this within our layout! Next, we place our grid item as child content, between the start and end tags, within our grid component, within our index page.</p><pre><code class="language-edge">// resources/views/pages/challenges/index.edge
@layout()

  &lt;div&gt;
    &lt;h1&gt;Challenges&lt;/h1&gt;
    &lt;p&gt;Welcome to the challenges page.&lt;/p&gt;

    @include('partials/challenge/available_points')

    @if (!challenges.length)
      &lt;p&gt;No challenges available.&lt;/p&gt;
    @else
      &lt;p&gt;Below are our available challenges:&lt;/p&gt;
    @endif

    @component('components/challenge/grid', { challenges })
++      @!component('components/challenge/grid_item')
    @endcomponent
  &lt;/div&gt;

@end</code></pre><h2>Slot Scopes</h2><p>The problem here is that we need access to our individual&nbsp;<code>challenge</code>&nbsp;being looped over for our grid item. One option we have is to use slot scopes. This is contextual information we can pass through the slot, via an argument to the slot method, to the child.</p><pre><code class="language-edge">// resources/views/components/challenge/grid.edge
&lt;div class="cards"&gt;
  @each(challenge in challenges)
++    {{{ await $slots.main({ challenge }) }}}
  @endeach
&lt;/div&gt;</code></pre><p>Then, we can use the&nbsp;<code>@slot</code>&nbsp;tag to access this scope for the child.</p><pre><code class="language-edge">// resources/views/pages/challenges/index.edge
@layout()

  &lt;div&gt;
    &lt;h1&gt;Challenges&lt;/h1&gt;
    &lt;p&gt;Welcome to the challenges page.&lt;/p&gt;

    @include('partials/challenge/available_points')

    @if (!challenges.length)
      &lt;p&gt;No challenges available.&lt;/p&gt;
    @else
      &lt;p&gt;Below are our available challenges:&lt;/p&gt;
    @endif

    @component('components/challenge/grid', { challenges })
++      @slot('main', scope)
++        @!component('components/challenge/grid_item', { challenge: scope.challenge })
++      @endslot
    @endcomponent
  &lt;/div&gt;

@end</code></pre><h2>Injected State</h2><p>We could alternatively skip the middleman, though, and inject the challenge directly from the grid component into the child grid item component.</p><pre><code class="language-edge">// resources/views/components/challenge/grid.edge
&lt;div class="cards"&gt;
  @each(challenge in challenges)
++    @inject({ challenge })
    {{{ await $slots.main() }}}
  @endeach
&lt;/div&gt;</code></pre><p>This injects our challenge into the&nbsp;<code>$context</code>&nbsp;of the child contents for this component. We can then access this&nbsp;<code>$context</code>&nbsp;directly inside our grid item component for use. Since the inject and slot occur for each loop, this will happen once per individual item in our array.</p><pre><code class="language-edge">// resources/views/components/challenge/grid_item.edge

++@let(challenge = $context.challenge)
&lt;a href="/challenges/{{ challenge.id }}"&gt;
  &lt;h3&gt;{{ challenge.text }}&lt;/h3&gt;
  &lt;p&gt;Points: {{ challenge.points }}&lt;/p&gt;
&lt;/a&gt;</code></pre><p>Lastly, we need to remove the middleman from our index page. If we dump the&nbsp;<code>$context</code>&nbsp;from our index page, it'll make a little more evident exactly what's happening here as well.</p><pre><code class="language-edge">// resources/views/pages/challenges/index.edge
@layout()

  &lt;div&gt;
    &lt;h1&gt;Challenges&lt;/h1&gt;
    &lt;p&gt;Welcome to the challenges page.&lt;/p&gt;

    @include('partials/challenge/available_points')

    @if (!challenges.length)
      &lt;p&gt;No challenges available.&lt;/p&gt;
    @else
      &lt;p&gt;Below are our available challenges:&lt;/p&gt;
    @endif

    @component('components/challenge/grid', { challenges })
++      @dump($context)
++      @!component('components/challenge/grid_item')
    @endcomponent
  &lt;/div&gt;

@end</code></pre><p>As you can see, we get three individual dumps, one per item we're looping over! Great, we can remove that dump. The last, and probably cleanest, option is to just loop directly within our page.</p><pre><code class="language-edge">// resources/views/pages/challenges/index.edge
@layout()

  &lt;div&gt;
    &lt;h1&gt;Challenges&lt;/h1&gt;
    &lt;p&gt;Welcome to the challenges page.&lt;/p&gt;

    @include('partials/challenge/available_points')

    @if (!challenges.length)
      &lt;p&gt;No challenges available.&lt;/p&gt;
    @else
      &lt;p&gt;Below are our available challenges:&lt;/p&gt;
    @endif

++    @component('components/challenge/grid')
++      @each(challenge in challenges)
++        @!component('components/challenge/grid_item', { challenge })
++      @endeach
++    @endcomponent
  &lt;/div&gt;

@end</code></pre><pre><code class="language-edge">// resources/views/components/challenge/grid.edge
&lt;div class="cards"&gt;
  {{{ await $slots.main() }}}
&lt;/div&gt;
</code></pre><pre><code class="language-edge">// resources/views/components/challenge/grid_item.edge
&lt;a href="/challenges/{{ challenge.id }}"&gt;
  &lt;h3&gt;{{ challenge.text }}&lt;/h3&gt;
  &lt;p&gt;Points: {{ challenge.points }}&lt;/p&gt;
&lt;/a&gt;</code></pre><h2>Component Tags</h2><p>Next, any component created within the&nbsp;<code>resources/views/components</code>&nbsp;folder automatically gets registered as a tag and can be used as such. This gives us a more convenient way to utilize components and cleaner markup to boot. Imagine the folder structure of the component file as a camel-cased nested JavaScript object. So,&nbsp;<code>challenge/grid_item</code>&nbsp;would be:</p><pre><code class="language-js">const challenge = {
  grid: '...',
  gridItem: '...'
}

challenge.grid
challenge.gridItem</code></pre><p>That is how we can access the component in tag form! Importantly, note that component tags do not have a corresponding named end tag to go with them. So here, we will just want to use&nbsp;<code>@end</code>&nbsp;to end the component.</p><pre><code class="language-edge">// resources/views/pages/challenges/index.edge
@layout()

  &lt;div&gt;
    &lt;h1&gt;Challenges&lt;/h1&gt;
    &lt;p&gt;Welcome to the challenges page.&lt;/p&gt;

    @include('partials/challenge/available_points')

    @if (!challenges.length)
      &lt;p&gt;No challenges available.&lt;/p&gt;
    @else
      &lt;p&gt;Below are our available challenges:&lt;/p&gt;
    @endif

++    @challenge.grid()
++      @each(challenge in challenges)
++        @!challenge.gridItem({ challenge })
++      @endeach
++    @end
  &lt;/div&gt;

@end</code></pre><p>Now, if you're thinking, hey this looks familiar, you're right! What we've just done is exactly how our layout works because the layout itself is a component. If you want to add another layout option, you can create a new component just like we have here.</p><p>Small note, if you have a component at&nbsp;<code>resources/views/components/challenge/index.edge</code>, you can access it as a tag with just&nbsp;<code>@challenge()</code>. The&nbsp;<code>index</code>&nbsp;will be implied if omitted!</p>]]></content:encoded>
      </item>
      <item>
        <title><![CDATA[Form Basics, Method Spoofing, & CSRF]]></title>
        <link>https://adocasts.com/lessons/form-basics-method-spoofing-and-csrf</link>
        <guid>https://adocasts.com/lessons/form-basics-method-spoofing-and-csrf</guid>
        <pubDate>Thu, 26 Mar 2026 11:00:00 +0000</pubDate>
        <description><![CDATA[Learn form handling in AdonisJS with method spoofing and CSRF protection. Build secure HTML forms with POST, PUT, and DELETE methods.]]></description>
        <content:encoded><![CDATA[<p>Next, let's work on allowing our users to create a new challenge by adding a form.</p><h2>Making A Create Page</h2><p>First, we'll need a page, and if you'll recall back to our resourceful naming, convention states this should be&nbsp;<code>/challenges/create</code>.</p><pre><code class="language-bash">node ace make:view pages/challenges/create
# DONE:    create resources/views/pages/challenges/create.edge</code></pre><pre><code class="language-ts">// start/routes.ts
// ...

router.get('/challenges', [controllers.Challenges, 'index'])
router.get('/challenges/:id', [controllers.Challenges, 'show'])
++router.get('/challenges/create', [controllers.Challenges, 'create'])

// ...</code></pre><p>Within the&nbsp;<code>create</code>&nbsp;method of our challenges controller, we'll render our new&nbsp;<code>create</code>&nbsp;page.</p><pre><code class="language-ts">// app/controllers/challenges_controller.ts
import type { HttpContext } from '@adonisjs/core/http'

const challenges = [
  { id: 1, text: 'Learn AdonisJS', points: 10 },
  { id: 2, text: 'Learn EdgeJS', points: 5 },
  { id: 3, text: 'Build an AdonisJS app', points: 20 },
]

export default class ChallengesController {
  // ...

  /**
   * Display form to create a new record
   */
++  async create({ view }: HttpContext) {
++    return view.render('pages/challenges/create')
++  }

  // ...
}</code></pre><p>Let's add a link for ourselves on our challenges page to point to this form. While we're here, we can clean this up a little as well.</p><pre><code class="language-edge">@layout()

  &lt;div&gt;
    &lt;div class="hero"&gt;
      &lt;h1&gt;Challenges&lt;/h1&gt;
  
      @include('partials/challenge/available_points')
  
      &lt;a href="/challenges/create" class="button"&gt;Create a new challenge&lt;/a&gt;
    &lt;/div&gt;

    @challenge.grid() 
      @each(challenge in challenges)
        @!challenge.gridItem({ challenge }) 
      @endeach
    @end
  &lt;/div&gt;

@end</code></pre><h2>Basic Form Submissions</h2><p>Okay, great! Now onto our create page. Here we'll want a form with the fields our challenge requires.</p><ul><li><p><code>text</code>&nbsp;for the name of the challenge</p></li><li><p><code>points</code>&nbsp;for the points our users get for completing the challenge</p></li></ul><pre><code class="language-edge">// resources/views/pages/challenges/create.edge
@layout()

  &lt;div class="form-container"&gt;
    &lt;div&gt;
      &lt;h1&gt; Create Challenge &lt;/h1&gt;
      &lt;p&gt; Enter your challenge details below &lt;/p&gt;
    &lt;/div&gt;

    &lt;div&gt;
      &lt;form action="/challenges" method="POST"&gt;
        &lt;div&gt;
          &lt;label&gt;
            Text
            &lt;input type="text" name="text" /&gt;
          &lt;/label&gt;
        &lt;/div&gt;

        &lt;div&gt;
          &lt;label&gt;
            Points
            &lt;input type="number" name="points" /&gt;
          &lt;/label&gt;
        &lt;/div&gt;

        &lt;div&gt;
          &lt;button type="submit" class="button"&gt; Create Challenge &lt;/button&gt;
        &lt;/div&gt;
      &lt;/form&gt;
    &lt;/div&gt;
  &lt;/div&gt;

@end</code></pre><p>Again, keeping with our resourceful naming, to store new records, we should send a POST request to&nbsp;<code>/challenges</code>. So, let's create that route next.</p><pre><code class="language-ts">// start/routes.ts
// ...

router.get('/challenges', [controllers.Challenges, 'index'])
router.get('/challenges/:id', [controllers.Challenges, 'show'])
router.get('/challenges/create', [controllers.Challenges, 'create'])
router.post('/challenges', [controllers.Challenges, 'store'])

// ...</code></pre><p>Next, for our controller, when we submit our form, the data within it will be sent up within our request's body. There are numerous ways we can get body data from our request. With&nbsp;<code>request.all()</code>&nbsp;it will simply return everything. Let's take a peek at what we're getting first.</p><pre><code class="language-ts">// app/controllers/challenges_controller.ts
import type { HttpContext } from '@adonisjs/core/http'

const challenges = [
  { id: 1, text: 'Learn AdonisJS', points: 10 },
  { id: 2, text: 'Learn EdgeJS', points: 5 },
  { id: 3, text: 'Build an AdonisJS app', points: 20 },
]

export default class ChallengesController {
  // ...

  /**
   * Handle form submission for the create action
   */
++  async store({ request }: HttpContext) {
++    const data = request.all()
++    console.log({ data })
++  }

  // ...
}</code></pre><h2>Cross-Site Request Forgery</h2><p>If we stop, fill out our form, and hit submit. You'll notice our browser refreshes and the form clears out. Let's check our server's log to see what we got!</p><pre><code class="language-bash">WARN (21151): Invalid or expired CSRF token
request_id: "fac1d913-f3a0-40d8-8da0-89996276225b"
x-request-id: "fac1d913-f3a0-40d8-8da0-89996276225b"</code></pre><p>What you'll find is a warning that the server recieved an "invalid or expired CSRF token," what's this about? CSRF stands for Cross-Site Request Forgery, and per OWASP:</p><blockquote><p>"Cross-Site Request Forgery (CSRF) is an attack that forces an end user to execute unwanted actions on a web application in which they’re currently authenticated. With a little help of social engineering (such as sending a link via email or chat), an attacker may trick the users of a web application into executing actions of the attacker’s choosing"</p></blockquote><ul><li><p>OWASP -&nbsp;<a target="_blank" rel="nofollow noopener noreferrer" href="https://owasp.org/www-community/attacks/csrf">https://owasp.org/www-community/attacks/csrf</a></p></li></ul><p>To protect against this, our server generates a token with every request. When we submit POST, PUT, PATCH, or DELETE requests from our application, it will expect that token to be sent along with that request as verification that the request originated from our site, as a way to protect against these malicious attacks. This token is referred to as the CSRF Token, and AdonisJS provides a utility method,&nbsp;<code>csrfField()</code>&nbsp;we can call to plop a CSRF token field into our forms.</p><pre><code class="language-edge">// resources/views/pages/challenges/create.edge
@layout()

  &lt;div class="form-container"&gt;
    &lt;div&gt;
      &lt;h1&gt; Create Challenge &lt;/h1&gt;
      &lt;p&gt; Enter your challenge details below &lt;/p&gt;
    &lt;/div&gt;

    &lt;div&gt;
      &lt;form action="/challenges" method="POST"&gt;
++        {{ csrfField() }}

        &lt;div&gt;
          &lt;label&gt;
            Text
            &lt;input type="text" name="text" /&gt;
          &lt;/label&gt;
        &lt;/div&gt;

        &lt;div&gt;
          &lt;label&gt;
            Points
            &lt;input type="number" name="points" /&gt;
          &lt;/label&gt;
        &lt;/div&gt;

        &lt;div&gt;
          &lt;button type="submit" class="button"&gt; Create Challenge &lt;/button&gt;
        &lt;/div&gt;
      &lt;/form&gt;
    &lt;/div&gt;
  &lt;/div&gt;

@end</code></pre><p>Perfect! Let's try our form one more time. This time, you'll notice we get a blank page after we submit. This is because we aren't sending anything back as a response; we'll change that in a moment. First, though, let's check our log.</p><pre><code class="language-bash">{
  data: {
    _csrf: '6ysucdLo-pdCD18tWTyeS8qRubQarXYs13BQ',
    text: 'Test',
    points: '10'
  }
}</code></pre><p>Awesome, this time we got our body data, and we can even see our CSRF token coming through to boot.</p><h2>Picking Body Data</h2><p>Now, it is never a good idea to take in any data the user sends. Always be defensive when it comes to the data your application accepts from users. So, instead of using&nbsp;<code>request.all()</code>&nbsp;we can switch this to only accept the properties we expect. For this, again, we have a couple of options.</p><pre><code class="language-ts">// app/controllers/challenges_controller.ts
export default class ChallengesController {
  // ...

  /**
   * Handle form submission for the create action
   */
  async store({ request }: HttpContext) {
++    const data = request.only(['text', 'points'])
++    const text = request.input('text')
++    const points = request.input('points')
++    console.log({ data, text, points })
  }

  // ...
}</code></pre><p>I want to note, though this is more defensive than using&nbsp;<code>request.all()</code>&nbsp;even these aren't ideal. We'll always want to validate user data. We'll cover that in the next lesson, so don't brush your hands off thinking you're done here.</p><pre><code class="language-ts">// app/controllers/challenges_controller.ts
import type { HttpContext } from '@adonisjs/core/http'

const challenges = [
  { id: 1, text: 'Learn AdonisJS', points: 10 },
  { id: 2, text: 'Learn EdgeJS', points: 5 },
  { id: 3, text: 'Build an AdonisJS app', points: 20 },
]

export default class ChallengesController {
  // ...

  /**
   * Handle form submission for the create action
   */
  async store({ request, response }: HttpContext) {
    const data = request.only(['text', 'points'])
    
++    challenges.push({ id: challenges.length + 1, ...data })
  }

  // ...
}</code></pre><p>Typically, we would take in data and store it within a database for long-term storage. We aren't there yet, so we'll work with what we have at the moment, which is our in-memory array. Note, since it's in-memory, anything we add or remove to it will be undone when we restart our server, as that destroys the memory. So, let's pick our&nbsp;<code>text</code>&nbsp;and&nbsp;<code>points</code>&nbsp;values off our request and push them as a new challenge into our&nbsp;<code>challenges</code>&nbsp;array. We'll also assign it an incremented ID.</p><h2>Handling Form Responses</h2><p>So that we aren't met with a blank page again, we can forward our user back to the challenges page so we can see our updated list.</p><pre><code class="language-ts">// app/controllers/challenges_controller.ts
import type { HttpContext } from '@adonisjs/core/http'

const challenges = [
  { id: 1, text: 'Learn AdonisJS', points: 10 },
  { id: 2, text: 'Learn EdgeJS', points: 5 },
  { id: 3, text: 'Build an AdonisJS app', points: 20 },
]

export default class ChallengesController {
  // ...

  /**
   * Handle form submission for the create action
   */
  async store({ request, response }: HttpContext) {
    const data = request.only(['text', 'points'])
    
    challenges.push({ id: challenges.length + 1, ...data })

++    return response.redirect('/challenges')
  }

  // ...
}</code></pre><p>Responses are how we send instructions and data from our server back to the user. On our response, in our HttpContext is a&nbsp;<code>redirect()</code>&nbsp;method. This will alter our response to a 302, redirect status code with the&nbsp;<code>Location</code>&nbsp;header set to the path we've specified. The browser will take these instructions and use them to redirect the user to the challenges page.</p><p>Now, if we submit our form, not only will we be redirected, but we'll also see our new challenge added to our list!</p><h2>Editing Data with a Form</h2><p>Next, let's add the ability to edit a challenge by first getting our edit page created.</p><pre><code class="language-bash">node ace make:view pages/challenges/edit
# DONE:    create resources/views/pages/challenges/edit.edge</code></pre><p>Then, we'll add two routes:</p><ul><li><p><code>GET: /challenges/:id/edit</code>&nbsp;to render the edit form</p></li><li><p><code>PUT: /challenges/:id</code>&nbsp;to handle the updating</p></li></ul><pre><code class="language-ts">// start/routes.ts
// ...

router.get('/challenges', [controllers.Challenges, 'index'])
router.get('/challenges/:id', [controllers.Challenges, 'show'])
router.get('/challenges/create', [controllers.Challenges, 'create'])
router.post('/challenges', [controllers.Challenges, 'store'])
++router.get('/challenges/:id/edit', [controllers.Challenges, 'edit'])
++router.put('/challenges/:id', [controllers.Challenges, 'update'])

// ...</code></pre><p>Next, we'll render our edit form within the&nbsp;<code>edit</code>&nbsp;method of our controller and update the challenge with the submitted data in our&nbsp;<code>update</code>&nbsp;method.</p><pre><code class="language-ts">// app/controllers/challenges_controller.ts
import type { HttpContext } from '@adonisjs/core/http'

const challenges = [
  { id: 1, text: 'Learn AdonisJS', points: 10 },
  { id: 2, text: 'Learn EdgeJS', points: 5 },
  { id: 3, text: 'Build an AdonisJS app', points: 20 },
]

export default class ChallengesController {
  // ...

  /**
   * Edit individual record
   */
  async edit({ params, view }: HttpContext) {
    // find the challenge being edited by its id
    const challenge = challenges.find((row) =&gt; row.id === params.id)

    // pass the challenge to the view
    return view.render('pages/challenges/edit', { challenge })
  }

  /**
   * Handle form submission for the edit action
   */
  async update({ params, request, response }: HttpContext) {
    const data = request.only(['text', 'points'])

    // find the challenge being updated by its id, and update its data
    const challengeIndex = challenges.findIndex((row) =&gt; row.id === params.id)
    challenges[challengeIndex] = { id: params.id, ...data }

    return response.redirect('/challenges')
  }

  // ...
}</code></pre><p>We then need to fill out our page. We can copy/paste our create page as a starting point, updating "create" to "edit." We also need to pre-populate the field's values with the values of the challenge we're editing. Finally, we need to update our form's action to point to the specific challenge ID we're looking to update.</p><pre><code class="language-edge">// resources/views/pages/challenges/edit.edge
@layout()

  &lt;div class="form-container"&gt;
    &lt;div&gt;
      &lt;h1&gt; Edit Challenge &lt;/h1&gt;
      &lt;p&gt; Enter your challenge details below &lt;/p&gt;
    &lt;/div&gt;

    &lt;div&gt;
      &lt;form action="/challenges/{{ challenge.id }}" method="POST"&gt;
        {{ csrfField() }}

        &lt;div&gt;
          &lt;label&gt;
            Text
            &lt;input type="text" name="text" value="{{ challenge.text }}" /&gt;
          &lt;/label&gt;
        &lt;/div&gt;

        &lt;div&gt;
          &lt;label&gt;
            Points
            &lt;input type="number" name="points" value="{{ challenge.points }}" /&gt;
          &lt;/label&gt;
        &lt;/div&gt;

        &lt;div&gt;
          &lt;button type="submit" class="button"&gt; Update Challenge &lt;/button&gt;
        &lt;/div&gt;
      &lt;/form&gt;
    &lt;/div&gt;
  &lt;/div&gt;

@end</code></pre><p>And, let's give ourselves a link to this page from our challenge's show page. I'm also going to add our "hero" class to our div as well.</p><pre><code class="language-edge">// resources/views/pages/challenges/show.edge
@layout()

  &lt;div class="hero"&gt;
    &lt;h1&gt;{{ challenge.text }}&lt;/h1&gt;
    &lt;p&gt;Points: {{ challenge.points }}&lt;/p&gt;

    &lt;a href="/challenges/{{ challenge.id }}/edit" class="button"&gt;Edit Challenge&lt;/a&gt;
  &lt;/div&gt;

@end</code></pre><h2>HTTP Method Spoofing</h2><p>If we now go to update one of our challenges, you'll notice we're met with a 404 exception.</p><pre><code>Cannot POST:/challenges/1</code></pre><p>The keyword of note here is "POST". We've defined this route using resourceful conventions, so its HTTP Verb is PUT and not POST. So, how do we fix this? Your first thought might be to switch the&nbsp;<code>method="POST"</code>&nbsp;attribute on our form to&nbsp;<code>method="PUT"</code>&nbsp;and that'd be fantastic, if browsers supported that. Unfortunately, they only support GET and POST as form methods. Instead, we need to use HTTP Method Spoofing. This allows us to send our request from the form as a POST but have our server understand it as a PUT, PATCH, or DELETE instead.</p><p>In AdonisJS 7, this is enabled by default, but just in case, you can find this within your&nbsp;<code>config/app.ts</code>&nbsp;file.</p><pre><code class="language-ts">// config/app.ts
/**
 * The configuration settings used by the HTTP server
 */
export const http = defineConfig({
  // ...

  /**
   * Allow method spoofing via _method query parameter or form field.
   * Enables using PUT, PATCH, DELETE methods in HTML forms by spoofing
   * through POST requests with _method field.
   */
  allowMethodSpoofing: true,

  // ...
})</code></pre><p>As noted here, to use it, all we need to do is include a&nbsp;<code>_method=PUT</code>&nbsp;query string on our form action's URL.</p><pre><code class="language-edge">// resources/views/pages/challenges/edit.edge
@layout()

  &lt;div class="form-container"&gt;
    &lt;div&gt;
      &lt;h1&gt; Edit Challenge &lt;/h1&gt;
      &lt;p&gt; Enter your challenge details below &lt;/p&gt;
    &lt;/div&gt;

    &lt;div&gt;
++      &lt;form action="/challenges/{{ challenge.id }}?_method=PUT" method="POST"&gt;
        {{ csrfField() }}

        &lt;div&gt;
          &lt;label&gt;
            Text
            &lt;input type="text" name="text" value="{{ challenge.text }}" /&gt;
          &lt;/label&gt;
        &lt;/div&gt;

        &lt;div&gt;
          &lt;label&gt;
            Points
            &lt;input type="number" name="points" value="{{ challenge.points }}" /&gt;
          &lt;/label&gt;
        &lt;/div&gt;

        &lt;div&gt;
          &lt;button type="submit" class="button"&gt; Update Challenge &lt;/button&gt;
        &lt;/div&gt;
      &lt;/form&gt;
    &lt;/div&gt;
  &lt;/div&gt;

@end</code></pre><p>Now, if we attempt to update a challenge once more... voila! All works according to plan.</p>]]></content:encoded>
      </item>
      <item>
        <title><![CDATA[Route Names & Type-Safe Route Generation]]></title>
        <link>https://adocasts.com/lessons/route-names-and-type-safe-route-generation</link>
        <guid>https://adocasts.com/lessons/route-names-and-type-safe-route-generation</guid>
        <pubDate>Thu, 26 Mar 2026 11:00:00 +0000</pubDate>
        <description><![CDATA[Learn type-safe route generation in AdonisJS using route names. Generate URLs with named routes for maintainable links and redirects. We'll also inspect the generated type for our route definitions and default route naming.]]></description>
        <content:encoded><![CDATA[<p>Thus far, we have been manually writing out the paths of URLs for our redirects, forms, and links. Today, we're going to learn how we can do it even better using names or identifiers for our routes instead of the actual paths.</p><h2>Naming Routes</h2><p>When we define routes, we can <a target="_blank" rel="nofollow noopener noreferrer" href="https://docs.adonisjs.com/guides/basics/routing#route-identifiers">assign them a name</a> by chaining an&nbsp;<code>as()</code>&nbsp;method on the route definition. We've actually already seen this with our home page.</p><pre><code class="language-ts">// start/routes.ts
router.on('/').render('pages/home').as('home')</code></pre><p>By naming this route definition, we can reference it using "home" instead of its path ("/"). Why might we want to do this?</p><p>The first reason is type-safety. When we generate or refer to these routes within TypeScript, AdonisJS takes steps to ensure we're using routes that are actually defined at the type level.</p><p>If we check out the&nbsp;<code>.adonisjs</code>&nbsp;folder again, remember this is where AdonisJS places its auto-generated files, we'll find a&nbsp;<code>routes.d.ts</code>&nbsp;file within the server folder. AdonisJS generates this file listing our defined routes and their required parameters as well. If you'll notice, so far, the&nbsp;<code>as()</code>&nbsp;method is only being used at home, yet all of these routes have names associated with them as the key.</p><pre><code class="language-ts">// .adonisjs/server/routes.d.ts
import '@adonisjs/core/types/http'

type ParamValue = string | number | bigint | boolean

export type ScannedRoutes = {
  ALL: {
    'home': { paramsTuple?: []; params?: {} }
    'challenges.index': { paramsTuple?: []; params?: {} }
    'challenges.show': { paramsTuple: [ParamValue]; params: {'id': ParamValue} }
    'challenges.create': { paramsTuple?: []; params?: {} }
    'challenges.store': { paramsTuple?: []; params?: {} }
    'challenges.edit': { paramsTuple: [ParamValue]; params: {'id': ParamValue} }
    'challenges.update': { paramsTuple: [ParamValue]; params: {'id': ParamValue} }
    'new_account.create': { paramsTuple?: []; params?: {} }
    'new_account.store': { paramsTuple?: []; params?: {} }
    'session.create': { paramsTuple?: []; params?: {} }
    'session.store': { paramsTuple?: []; params?: {} }
    'session.destroy': { paramsTuple?: []; params?: {} }
  }
  GET: {
    'home': { paramsTuple?: []; params?: {} }
    'challenges.index': { paramsTuple?: []; params?: {} }
    'challenges.show': { paramsTuple: [ParamValue]; params: {'id': ParamValue} }
    'challenges.create': { paramsTuple?: []; params?: {} }
    'challenges.edit': { paramsTuple: [ParamValue]; params: {'id': ParamValue} }
    'new_account.create': { paramsTuple?: []; params?: {} }
    'session.create': { paramsTuple?: []; params?: {} }
  }
  HEAD: {
    'home': { paramsTuple?: []; params?: {} }
    'challenges.index': { paramsTuple?: []; params?: {} }
    'challenges.show': { paramsTuple: [ParamValue]; params: {'id': ParamValue} }
    'challenges.create': { paramsTuple?: []; params?: {} }
    'challenges.edit': { paramsTuple: [ParamValue]; params: {'id': ParamValue} }
    'new_account.create': { paramsTuple?: []; params?: {} }
    'session.create': { paramsTuple?: []; params?: {} }
  }
  POST: {
    'challenges.store': { paramsTuple?: []; params?: {} }
    'new_account.store': { paramsTuple?: []; params?: {} }
    'session.store': { paramsTuple?: []; params?: {} }
    'session.destroy': { paramsTuple?: []; params?: {} }
  }
  PUT: {
    'challenges.update': { paramsTuple: [ParamValue]; params: {'id': ParamValue} }
  }
}
declare module '@adonisjs/core/types/http' {
  export interface RoutesList extends ScannedRoutes {}
}</code></pre><p>This is because AdonisJS will now default to a name built from the controller's name and the method used for the route from that controller, giving us&nbsp;<code>challenges.index</code>&nbsp;as an auto-generated name for our&nbsp;<code>/challenges</code>&nbsp;route. When we refer to routes using their names, AdonisJS will use this file to ensure that the route exists and that we meet its requirements.</p><p>Let's start with our redirects within our&nbsp;<code>ChallengesController</code>&nbsp;as this will allow us to get TypeScript feedback. Instead of doing&nbsp;<code>response.redirect('/challenges')</code>, we can chain an additional&nbsp;<code>toRoute()</code>&nbsp;method off our redirection.</p><pre><code class="language-ts">// app/controllers/challenges_controller.ts
export default class ChallengesController {
  // ...

  async store({ request, response }: HttpContext) {
    const data = await request.validateUsing(challengeValidator)

    challenges.push({ id: challenges.length + 1, ...data })

++    return response.redirect().toRoute('challenges.index')
  }

  // ...
}</code></pre><p>This accepts the route's name as the first argument and any route parameters as an object in the second. So if, for example, we wanted to redirect the user back to the challenge show page after updating, we could do:</p><pre><code class="language-ts">// app/controllers/challenges_controller.ts
export default class ChallengesController {
  // ...

  async update({ params, request, response }: HttpContext) {
    const data = await request.validateUsing(challengeValidator)

    // find the challenge being updated by its id, and update its data
    const challengeIndex = challenges.findIndex((row) =&gt; row.id === params.id)
    challenges[challengeIndex] = { id: params.id, ...data }

++    return response.redirect().toRoute('challenges.show', { id: params.id })
  }

  // ...
}</code></pre><p>Note, with this, if we get the identifier wrong or omit the params, we get a type error to match, keeping things type-safe.</p><p>The other reason we might want to use identifiers is that route patterns are prone to change over time. By using the route's name instead of the pattern, we're saving ourselves the pain of having to search down all of the route's usages because the name we're using will automatically pick up pattern changes.</p><h2>Generating Route URLs</h2><p>We can also get access to the <a target="_blank" rel="nofollow noopener noreferrer" href="https://docs.adonisjs.com/guides/basics/url-builder">URL builder</a> that&nbsp;<code>toRoute</code>&nbsp;is used under the hood to build out a URL via the identifier as well. For example:</p><pre><code class="language-ts">// app/controllers/challenges_controller.ts
import { urlFor } from '@adonisjs/core/services/url_builder'
export default class ChallengesController {
  // ...

  async show({ view, params }: HttpContext) {
    const challenge = challenges.find((row) =&gt; row.id === params.id)
++    const editUrl = urlFor('challenges.edit', { id: params.id })

++    return view.render('pages/challenges/show', { challenge, editUrl })
  }

  // ...
}</code></pre><p>Finally, this is also available within our EdgeJS templates via a global helper method called&nbsp;<code>route</code>.</p><pre><code class="language-edge">// resources/views/pages/challenges/create.edge
@layout()

  &lt;div&gt;
    &lt;div class="hero"&gt;
      &lt;h1&gt;Challenges&lt;/h1&gt;
  
      @include('partials/challenge/available_points')
  
++      &lt;a href="{{ route('challenges.create') }}" class="button"&gt;Create a new challenge&lt;/a&gt;
    &lt;/div&gt;

    @challenge.grid() 
      @each(challenge in challenges)
        @!challenge.gridItem({ challenge }) 
      @endeach
    @end
  &lt;/div&gt;

@end</code></pre><p>We've got a few spots to update this.</p><pre><code class="language-edge">// resources/views/components/challenge/grid_item.edge

++&lt;a href="{{ route('challenges.show', { id: challenge.id }) }}"&gt;
  &lt;h3&gt;{{ challenge.text }}&lt;/h3&gt;
  &lt;p&gt;Points: {{ challenge.points }}&lt;/p&gt;
&lt;/a&gt;</code></pre><p>We gave ourselves an&nbsp;<code>editUrl</code>&nbsp;on our show page, so we can make use of that if we'd like.</p><pre><code class="language-edge">// resources/views/pages/challenges/show.edge
@layout()

  &lt;div class="hero"&gt;
    &lt;h1&gt;{{ challenge.text }}&lt;/h1&gt;
    &lt;p&gt;Points: {{ challenge.points }}&lt;/p&gt;

++    &lt;a href="{{ editUrl }}" class="button"&gt;Edit Challenge&lt;/a&gt;
  &lt;/div&gt;

@end</code></pre><h2>Form Component</h2><p>Finally, we have our forms. Like the fields, the starter kit also gave us a form component which will automatically include our&nbsp;<code>csrfField()</code>&nbsp;on non-GET requests! This component accepts the route identifier and route params as separate props, then builds those into our route URL for us.</p><pre><code class="language-edge">// resources/views/pages/challenges/create.edge
@layout()

  &lt;div class="form-container"&gt;
    &lt;div&gt;
      &lt;h1&gt; Create Challenge &lt;/h1&gt;
      &lt;p&gt; Enter your challenge details below &lt;/p&gt;
    &lt;/div&gt;

    &lt;div&gt;
      @form({ route: 'challenges.store', method: 'POST' })
        &lt;div&gt;
          @field.root({ name: 'text' })
            @!field.label({ text: 'Text' })
            @!input.control({ type: 'text' })
            @!field.error()
          @end
        &lt;/div&gt;

        &lt;div&gt;
          @field.root({ name: 'points' })
            @!field.label({ text: 'Points' })
            @!input.control({ type: 'number' })
            @!field.error()
          @end
        &lt;/div&gt;

        &lt;div&gt;
          &lt;button type="submit" class="button"&gt; Create Challenge &lt;/button&gt;
        &lt;/div&gt;
      @end
    &lt;/div&gt;
  &lt;/div&gt;

@end</code></pre><p>Of note, the form component also automatically handles our HTTP method spoofing, so we can directly set the method here as "PUT" and it'll do the rest.</p><pre><code class="language-edge">// resources/views/pages/challenges/edit.edge
@layout()

  &lt;div class="form-container"&gt;
    &lt;div&gt;
      &lt;h1&gt; Edit Challenge &lt;/h1&gt;
      &lt;p&gt; Enter your challenge details below &lt;/p&gt;
    &lt;/div&gt;

    &lt;div&gt;
      @form({ method: 'PUT', route: 'challenges.update', routeParams: { id: challenge.id } })
        &lt;div&gt;
          @field.root({ name: 'text' })
            @!field.label({ text: 'Text' })
            @!input.control({ type: 'text', value: challenge.text })
            @!field.error()
          @end
        &lt;/div&gt;

        &lt;div&gt;
          @field.root({ name: 'points' })
            @!field.label({ text: 'Points' })
            @!input.control({ type: 'number', value: challenge.points })
            @!field.error()
          @end
        &lt;/div&gt;

        &lt;div&gt;
          &lt;button type="submit" class="button"&gt; Update Challenge &lt;/button&gt;
        &lt;/div&gt;
      @end
    &lt;/div&gt;
  &lt;/div&gt;

@end</code></pre>]]></content:encoded>
      </item>
      <item>
        <title><![CDATA[Route Parameters & Matchers]]></title>
        <link>https://adocasts.com/lessons/route-parameters-and-matchers</link>
        <guid>https://adocasts.com/lessons/route-parameters-and-matchers</guid>
        <pubDate>Mon, 16 Mar 2026 11:00:00 +0000</pubDate>
        <description><![CDATA[Learn AdonisJS route parameters and matchers. Capture dynamic segments in URLs and validate and cast with route matchers.]]></description>
        <content:encoded><![CDATA[<p>Now, when it comes time to show details about a specific competition, we don't want to define a route for each individual competition. Instead, we want to allow the route to accept a dynamic identifier for the competition we're after. That's where route parameters come into play. They allow us to specify one or more portions of a route's pattern as dynamic under the pattern name we provide.</p><p>For example, if we want to identify a competition by its&nbsp;<code>id</code>, we can define the pattern as&nbsp;<code>/competitions/:id</code>. <a target="_blank" rel="nofollow noopener noreferrer" href="https://docs.adonisjs.com/guides/basics/routing#route-params">Route parameters</a> are designated by a colon (:), and the term that follows is the parameter's name, the property with which we can access the dynamic value.</p><pre><code class="language-ts">// start/routes.ts
router.get('/challenge/:id', async (ctx) =&gt; {
  const challengeId = ctx.params.id
  return challengeId
})</code></pre><p>Our HttpContext provides a&nbsp;<code>params</code>&nbsp;object containing parsed route parameters. Since we've named our param&nbsp;<code>id</code>, that is the property name we can access the value with. Up til now, we've rendered an HTML page using EdgeJS. For now, let's just simply return this ID to see what we get!</p><p>When we request this with our browser, we get back a plaintext response. By default, AdonisJS will utilize content negotiation to determine the appropriate content type to respond with using the&nbsp;<code>Accept</code>&nbsp;header. If we instead specify this specifically as a JSON response, using the response on our HttpContext, we'll see our browser switches to a JSON viewer to match the new content-type of our response.</p><pre><code class="language-ts">// start/routes.ts
router.get('/challenge/:id', async (ctx) =&gt; {
  const challengeId = ctx.params.id
++  return ctx.response.json({ id: challengeId })
})</code></pre><h2>Route Machers/Validators</h2><p>If you noticed, our value is coming back as a string. Meaning, if we were to move our challenges array out where this route can utilize it as well, we would need to convert the type of the parameter in order to find a 1:1 strict-equality match.</p><pre><code class="language-ts">// start/routes.ts
import { controllers } from '#generated/controllers'
import { middleware } from '#start/kernel'
import router from '@adonisjs/core/services/router'

++const challenges = [
++  { id: 1, text: 'Learn AdonisJS', points: 10 },
++  { id: 2, text: 'Learn EdgeJS', points: 5 },
++  { id: 3, text: 'Build an AdonisJS app', points: 20 },
++]

router.on('/').render('pages/home').as('home')

router.on('/terms').render('pages/terms')

router.get('/challenges', async (ctx) =&gt; {
  return ctx.view.render('pages/challenges/index', { challenges })
})

router.get('/challenge/:id', async (ctx) =&gt; {
++  const challenge = challenges.find((row) =&gt; row.id === Number(ctx.params.id))
++  return ctx.response.json({ challenge })
})</code></pre><p>Instead of doing this, we can utilize <a target="_blank" rel="nofollow noopener noreferrer" href="https://docs.adonisjs.com/guides/basics/routing#route-param-validation">route parameter matches and validation</a> to:</p><ol><li><p>Ensure the ID is a valid number for this route pattern to actually be matched against</p></li><li><p>Cast the ID route parameter to a number within our&nbsp;<code>params</code>&nbsp;object.</p></li></ol><pre><code class="language-ts">// start/routes.ts
router.get('/challenge/:id', async (ctx) =&gt; {
  const challenge = challenges.find((row) =&gt; row.id === Number(ctx.params.id))
  return ctx.response.json({ challenge })
++}).where('id', /^[0-9]+$/)</code></pre><p>Here, we're using regex to merely ensure the&nbsp;<code>id</code>&nbsp;route parameter is a number. If it isn't, the route won't be matched, and currently, that would result in us getting a 404 Not Found exception.</p><p>If we wanted to cast it to a number, we could add a cast to the validation.</p><pre><code class="language-ts">// start/routes.ts
router
  .get('/challenge/:id', async (ctx) =&gt; {
++    const challenge = challenges.find((row) =&gt; row.id === ctx.params.id)
    return ctx.response.json({ challenge })
  })
++  .where('id', { 
++    match: /^[0-9]+$/,
++    cast: (value) =&gt; Number(value)
++  })</code></pre><p>Now, we're both ensuring it's a number and casting it from a string value to a number value. So our&nbsp;<code>ctx.params.id</code>&nbsp;is actually a number now, meaning we can get rid of the cast there. This is a common scenario, and AdonisJS knows that, so there is a convenient matcher we can use to perform this exact flow in a simplified manner.</p><pre><code class="language-ts">// start/routes.ts
router.get('/challenge/:id', async (ctx) =&gt; {
  const challenge = challenges.find((row) =&gt; row.id === ctx.params.id)
  return ctx.response.json({ challenge })
++}).where('id', router.matchers.number())</code></pre><p>You'll notice there is also a matcher for slug and UUID, doing similar things there. If, like our IDs, you're always going to want a route parameter to be cast or validated, we can apply this globally across all our routes.</p><pre><code class="language-ts">// start/routes.ts
import { controllers } from '#generated/controllers'
import { middleware } from '#start/kernel'
import router from '@adonisjs/core/services/router'

const challenges = [
  { id: 1, text: 'Learn AdonisJS', points: 10 },
  { id: 2, text: 'Learn EdgeJS', points: 5 },
  { id: 3, text: 'Build an AdonisJS app', points: 20 },
]

++router.where('id', router.matchers.number())

router.on('/').render('pages/home').as('home')

router.on('/terms').render('pages/terms')

router.get('/challenges', async (ctx) =&gt; {
  return ctx.view.render('pages/challenges/index', { challenges })
})

router.get('/challenge/:id', async (ctx) =&gt; {
  const challenge = challenges.find((row) =&gt; row.id === ctx.params.id)
  return ctx.response.json({ challenge })
})</code></pre><p>Now, anytime we use the name&nbsp;<code>id</code>&nbsp;for a route parameter, our number matcher will be used to validate and cast it.</p><p>Okay, finally, let's rig this up to a page!</p><pre><code class="language-bash">node ace make:view pages/challenges/show</code></pre><pre><code class="language-edge">// resources/views/pages/challenges/show.edge
@layout()

  &lt;div&gt;
    &lt;h1&gt;{{ challenge.text }}&lt;/h1&gt;
    &lt;p&gt;Points: {{ challenge.points }}&lt;/p&gt;
  &lt;/div&gt;

@end</code></pre><p>Then, we need to render the page instead of returning JSON as well. We can also spread specific properties out of our HttpContext as well to simplify our code a little as well.</p><pre><code class="language-ts">// start/routes.ts
router.get('/challenge/:id', async ({ view, params }) =&gt; {
  const challenge = challenges.find((row) =&gt; row.id === params.id)
  return view.render('pages/challenges/show', { challenge })
})</code></pre><p>We can now also link to this page from our&nbsp;<code>/challenges</code>&nbsp;page using an anchor element.</p><pre><code class="language-edge">// resources/views/pages/challenges/index.edge
@let(availablePoints = challenges.reduce((total, challenge) =&gt; total + challenge.points, 0))

@if (completedChallenges?.length)
  @let(completedPoints = completedChallenges.reduce((total, challenge) =&gt; total + challenge.points, 0))
  @assign(availablePoints = availablePoints - completedPoints)
@endif

@layout()

  &lt;div&gt;
    &lt;h1&gt;Challenges&lt;/h1&gt;
    &lt;p&gt;Welcome to the challenges page.&lt;/p&gt;

    &lt;div&gt;
      Total Points Available: {{ availablePoints }}
    &lt;/div&gt;

    @if (!challenges.length)
      &lt;p&gt;No challenges available.&lt;/p&gt;
    @else
      &lt;p&gt;Below are our available challenges:&lt;/p&gt;
    @endif

    &lt;ul&gt;
      @each(challenge in challenges)
        &lt;li&gt;
++          &lt;a href="/challenges/{{ challenge.id }}"&gt;
            &lt;h3&gt;{{ challenge.text }}&lt;/h3&gt;
            &lt;p&gt;Points: {{ challenge.points }}&lt;/p&gt;
          &lt;/a&gt;
        &lt;/li&gt;
      @endeach
    &lt;/ul&gt;
  &lt;/div&gt;

@end</code></pre><p>Our interpolation here will inject the challenge ID we're currently looping over into the&nbsp;<code>href</code>&nbsp;path to form our completed URL. We'll expand on this later on with a more sustainable linking approach.</p>]]></content:encoded>
      </item>
      <item>
        <title><![CDATA[Controllers, Barrel Files, & Subpath Imports]]></title>
        <link>https://adocasts.com/lessons/controllers-barrel-files-and-subpath-imports</link>
        <guid>https://adocasts.com/lessons/controllers-barrel-files-and-subpath-imports</guid>
        <pubDate>Mon, 16 Mar 2026 11:00:00 +0000</pubDate>
        <description><![CDATA[Learn AdonisJS controllers, barrel files, and subpath imports. Organize code with controllers and leverage barrel files for cleaner imports.]]></description>
        <content:encoded><![CDATA[<p>Thus far, the two challenge routes we've added are using a callback function to handle the route.</p><pre><code class="language-js">// start/routes.ts
import { controllers } from '#generated/controllers'
import { middleware } from '#start/kernel'
import router from '@adonisjs/core/services/router'

const challenges = [
  { id: 1, text: 'Learn AdonisJS', points: 10 },
  { id: 2, text: 'Learn EdgeJS', points: 5 },
  { id: 3, text: 'Build an AdonisJS app', points: 20 },
]

router.where('id', router.matchers.number())

router.on('/').render('pages/home').as('home')

router.on('/terms').render('pages/terms')

router.get('/challenges', async (ctx) =&gt; {
  return ctx.view.render('pages/challenges/index', { challenges })
})

router.get('/challenge/:id', async ({ view, params }) =&gt; {
  const challenge = challenges.find((row) =&gt; row.id === params.id)
  return view.render('pages/challenges/show', { challenge })
})

// ...</code></pre><p>This might be fine for small applications, but scales poorly for anything beyond a few routes. The remedy for this is controllers. Controllers are classes with dedicated methods to serve as our route handlers. This moves the logic of our routes from the route definitions into dedicated controller files, typically grouped by resource, like our challenge resource.</p><h2>Creating Controllers</h2><p>We can quickly create a new <a target="_blank" rel="nofollow noopener noreferrer" href="https://docs.adonisjs.com/guides/basics/controllers">controller</a> using the Ace CLI, but before we do, let's review the help options for this command.</p><pre><code class="language-bash">node ace make:controller --help
# 
# Description:
#   Create a new HTTP controller class
# 
# Usage:
#   node ace make:controller [options] [--] &lt;name&gt; [&lt;actions...&gt;]
# 
# Arguments:
#   name            The name of the controller
#   [actions...]    Create controller with custom method names
# 
# Options:
#   -s, --singular  Generate controller in singular form
#   -r, --resource  Generate resourceful controller with methods to perform CRUD actions on a resource
#   -a, --api       Generate resourceful controller without the "edit" and the "create" methods</code></pre><p>The naming convention for controllers is for them to be plural, and AdonisJS will help us out with that by automatically pluralizing the name we provide it for our controller. If we want to bypass that, we can add&nbsp;<code>--singular</code>, or&nbsp;<code>-s</code>&nbsp;for short.</p><p>If you'll recall from a few lessons ago, we discussed resourceful naming conventions with index, create, store, edit, update, and delete. To create a <a target="_blank" rel="nofollow noopener noreferrer" href="https://docs.adonisjs.com/guides/basics/controllers#resource-driven-controllers">resourceful controller</a> with methods stubbed to handle each of those actions, we can add&nbsp;<code>--resource</code>, or&nbsp;<code>-r</code>&nbsp;for short.</p><p>If you're working on an API, you won't need the create or edit, so you can create a resource with those omitted with&nbsp;<code>--api</code>&nbsp;or&nbsp;<code>-a</code>&nbsp;for short.</p><p>Finally, we could also manually specify methods we want to stub or provide nothing at all to get a simple class. Let's go ahead and create this as a resourceful controller.</p><pre><code class="language-bash">node ace make:controller challenge -r
# DONE:    create app/controllers/challenges_controller.ts</code></pre><p>Controllers can be found within our&nbsp;<code>app/controllers</code>&nbsp;folder. Again, note this automatically pluralized "challenge" to "challenges" for our controller name. Once we jump into this new file, we should see the following.</p><pre><code class="language-ts">// app/controllers/challenges_controller.ts
import type { HttpContext } from '@adonisjs/core/http'

export default class ChallengesController {
  /**
   * Display a list of resource
   */
  async index({}: HttpContext) {}

  /**
   * Display form to create a new record
   */
  async create({}: HttpContext) {}

  /**
   * Handle form submission for the create action
   */
  async store({ request }: HttpContext) {}

  /**
   * Show individual record
   */
  async show({ params }: HttpContext) {}

  /**
   * Edit individual record
   */
  async edit({ params }: HttpContext) {}

  /**
   * Handle form submission for the edit action
   */
  async update({ params, request }: HttpContext) {}

  /**
   * Delete record
   */
  async destroy({ params }: HttpContext) {}
}</code></pre><p>First thing to note here is that each of the method's arguments is typed with HttpContext. Within our route definition's callback methods, the HttpContext can be automatically typed by the definition. In our controller context, however, that inference can't happen, so a type is needed. Second, AdonisJS has started each method with a request extracted out of the HttpContext for methods expecting data to be sent up, like creating and updating records. Params are extracted for those that typically use a route parameter to identify a single record, like our challenge's id.</p><p>So, for us, we want to move our challenges array to the top of this file, so we still have that data to work with.</p><pre><code class="language-ts">// app/controllers/challenges_controller.ts
import type { HttpContext } from '@adonisjs/core/http'

++const challenges = [
++  { id: 1, text: 'Learn AdonisJS', points: 10 },
++  { id: 2, text: 'Learn EdgeJS', points: 5 },
++  { id: 3, text: 'Build an AdonisJS app', points: 20 },
++]

export default class ChallengesController {
  // ...
}</code></pre><p>Then, we can take the inner contents of our&nbsp;<code>/challenges</code>&nbsp;route definition and move it into our&nbsp;<code>index</code>&nbsp;method. We can also extract&nbsp;<code>view</code>&nbsp;out of the HttpContext for consistency.</p><pre><code class="language-ts">// app/controllers/challenges_controller.ts
import type { HttpContext } from '@adonisjs/core/http'

const challenges = [
  { id: 1, text: 'Learn AdonisJS', points: 10 },
  { id: 2, text: 'Learn EdgeJS', points: 5 },
  { id: 3, text: 'Build an AdonisJS app', points: 20 },
]

export default class ChallengesController {
  /**
   * Display a list of resource
   */
++  async index({ view }: HttpContext) {
++    return view.render('pages/challenges/index', { challenges })
  }
  
  // ...
}</code></pre><p>Then, we can do the same for our&nbsp;<code>/challenges/:id</code>&nbsp;route definition. This route's job is to show a single record, so its resourceful method will be&nbsp;<code>show</code>.</p><pre><code class="language-ts">// app/controllers/challenges_controller.ts
import type { HttpContext } from '@adonisjs/core/http'

const challenges = [
  { id: 1, text: 'Learn AdonisJS', points: 10 },
  { id: 2, text: 'Learn EdgeJS', points: 5 },
  { id: 3, text: 'Build an AdonisJS app', points: 20 },
]

export default class ChallengesController {
  /**
   * Display a list of resource
   */
  async index({ view }: HttpContext) {
    return view.render('pages/challenges/index', { challenges })
  }

  // ...

  /**
   * Show individual record
   */
++  async show({ view, params }: HttpContext) {
++    const challenge = challenges.find((row) =&gt; row.id === params.id)
++    return view.render('pages/challenges/show', { challenge })
  }
  
  // ...
}</code></pre><h2>Using Controllers</h2><p>We then need to update our route definitions to point to these controller methods instead of using the callback function. For this, we'll provide an array as the second argument to our route definition with our controller and the designated method to be used to handle the route. You'll also notice that AdonisJS provides a nice type inference to give us an autocomplete list of the controller's methods to pick from as well.</p><pre><code class="language-js">// start/routes.ts
import { controllers } from '#generated/controllers'
import { middleware } from '#start/kernel'
import router from '@adonisjs/core/services/router'
++const ChallengesController = () =&gt; import('#controllers/challenges_controller')

const challenges = [
  { id: 1, text: 'Learn AdonisJS', points: 10 },
  { id: 2, text: 'Learn EdgeJS', points: 5 },
  { id: 3, text: 'Build an AdonisJS app', points: 20 },
]

router.where('id', router.matchers.number())

router.on('/').render('pages/home').as('home')

router.on('/terms').render('pages/terms')

++router.get('/challenges', [ChallengesController, 'index'])
++router.get('/challenge/:id', [ChallengesController, 'show'])

// ...</code></pre><p>Here, AdonisJS prefers lazy-loaded controllers, hence the wrapped import. This helps ensure a speedy boot as we get more and more controllers to be imported.</p><h2>Barrel Files &amp; Subpath Imports</h2><p>New in AdonisJS 7 is a <a target="_blank" rel="nofollow noopener noreferrer" href="https://docs.adonisjs.com/guides/basics/controllers#the-barrel-file">new barrel file generation step</a>. If we look at the top of our imports and the starter kit's pre-defined routes, we'll notice&nbsp;<code>controllers</code>&nbsp;is being imported from a&nbsp;<code>#generated/controllers</code>&nbsp;location.</p><p>This location is utilizing a <a target="_blank" rel="nofollow noopener noreferrer" href="https://nodejs.org/api/packages.html#subpath-imports">NodeJS Subpath Import</a>, which is an alias import location to simplify our import paths throughout our project. Let's start here, these are defined within our&nbsp;<code>package.json</code>&nbsp;file, and AdonisJS comes with several out-of-the-box.</p><pre><code class="language-json">// package.json
{
  "": "...",

  "imports": {
    "#controllers/*": "./app/controllers/*.js",
    "#exceptions/*": "./app/exceptions/*.js",
    "#models/*": "./app/models/*.js",
    "#mails/*": "./app/mails/*.js",
    "#services/*": "./app/services/*.js",
    "#listeners/*": "./app/listeners/*.js",
    "#generated/*": "./.adonisjs/server/*.js",
    "#events/*": "./app/events/*.js",
    "#middleware/*": "./app/middleware/*.js",
    "#validators/*": "./app/validators/*.js",
    "#providers/*": "./providers/*.js",
    "#policies/*": "./app/policies/*.js",
    "#abilities/*": "./app/abilities/*.js",
    "#database/*": "./database/*.js",
    "#tests/*": "./tests/*.js",
    "#start/*": "./start/*.js",
    "#config/*": "./config/*.js"
  },

  "": "...",
}</code></pre><p>This is saying, anytime we're importing from&nbsp;<code>#generated</code>&nbsp;to look within&nbsp;<code>./.adonisjs/server/</code>&nbsp;from our project root. So, our&nbsp;<code>#generated/controllers</code>&nbsp;import location is actually&nbsp;<code>./.adonisjs/server/controllers.ts</code>&nbsp;within our project.</p><p>The&nbsp;<code>.adonisjs</code>&nbsp;folder is a new folder to AdonisJS 7 for these automatically generated files to improve type-safety throughout AdonisJS applications. Generated types and files to be used on the server-side will be within&nbsp;<code>.adonisjs/server</code>, while client-side safe types will be within&nbsp;<code>.adonisjs/client</code>. Since these are generated, they shouldn't be manually updated, as any updates will be overwritten.</p><p>So, if we open our generated controllers file, we'll see something like:</p><pre><code class="language-ts">// .adonisjs/server/controllers.ts
export const controllers = {
  Challenges: () =&gt; import('#controllers/challenges_controller'),
  NewAccount: () =&gt; import('#controllers/new_account_controller'),
  Session: () =&gt; import('#controllers/session_controller'),
}</code></pre><p>These generated files are created during and while our dev server is booted. So if you stop your server and create a controller, this won't update until we boot the dev server back up.</p><p>As we can see, this is an object of keys named after our controllers pointing to their import locations, again using lazy-loading as well to keep things loading efficiently. This is called a barrel file, and its purpose is to cut back on import clutter. Rather than importing every controller our application uses, we can instead import this one&nbsp;<code>controllers</code>&nbsp;variable and reference the controller's import location from here.</p><p>So, to use this, we can swap our import and class for&nbsp;<code>controllers.Challenges</code>. This, too, will give us a type inference autocomplete list of methods from the controller, same as we had before!</p><pre><code class="language-js">// start/routes.ts
import { controllers } from '#generated/controllers'
import { middleware } from '#start/kernel'
import router from '@adonisjs/core/services/router'

const challenges = [
  { id: 1, text: 'Learn AdonisJS', points: 10 },
  { id: 2, text: 'Learn EdgeJS', points: 5 },
  { id: 3, text: 'Build an AdonisJS app', points: 20 },
]

router.where('id', router.matchers.number())

router.on('/').render('pages/home').as('home')

router.on('/terms').render('pages/terms')

++router.get('/challenges', [controllers.Challenges, 'index'])
++router.get('/challenge/:id', [controllers.Challenges, 'show'])

// ...</code></pre><p>When we request our&nbsp;<code>/challenges</code>&nbsp;route now, AdonisJS will instantiate an instance of our&nbsp;<code>ChallengesController</code>&nbsp;and execute the&nbsp;<code>index</code>&nbsp;method, passing it the HttpContext. Each request gets its own isolated controller instance.</p><h2>Resources</h2><p>Since we're using a <a target="_blank" rel="nofollow noopener noreferrer" href="https://docs.adonisjs.com/guides/basics/controllers#resource-driven-controllers">resourceful controller</a>, AdonisJS provides a utility route definition to automatically define a route for each of the resourceful actions. So, we could define all six of these resourceful routes for our challenges with:</p><pre><code class="language-js">// start/routes.ts
router.resource('challenges', controllers.Challenges)</code></pre><p>This takes the name we've provided to define the base of all these routes as&nbsp;<code>/challenges</code>&nbsp;and it will then add the&nbsp;<code>id</code>&nbsp;route parameter where appropriate. The route handlers will also map automatically to the correct controller method for the route as well.</p><p>If we created an API resource, we could add&nbsp;<code>apiOnly()</code>&nbsp;to this to omit the create and edit routes. We can also use&nbsp;<code>only()</code>&nbsp;to specify specific resource routes to define.</p><pre><code class="language-js">// start/routes.ts
router.resource('challenges', controllers.Challenges).only(['index', 'show'])</code></pre><p>We have a little more to learn yet, however, so I'm going to keep these as separate routes for now.</p><pre><code class="language-js">// start/routes.ts
router.get('/challenges', [controllers.Challenges, 'index'])
router.get('/challenge/:id', [controllers.Challenges, 'show'])</code></pre>]]></content:encoded>
      </item>
      <item>
        <title><![CDATA[The Road Ahead & A Brief Break]]></title>
        <link>https://adocasts.com/blog/the-road-ahead-and-a-brief-break</link>
        <guid>https://adocasts.com/blog/the-road-ahead-and-a-brief-break</guid>
        <pubDate>Sat, 14 Mar 2026 16:58:00 +0000</pubDate>
        <description><![CDATA[I'm planning a little break, we'll talk about why, how long, and what that means for Adocasts going forward.]]></description>
        <content:encoded><![CDATA[<p>With the awesome release of AdonisJS v7, I've been busy working on producing my <a target="_blank" rel="nofollow noopener noreferrer" href="https://adocasts.com/series/lets-learn-adonisjs-7">Let's Learn AdonisJS 7 series</a>. I hope everyone has enjoyed what's out thus far! My goal is to get this series completed by the end of May, knowing that I still have eleven lessons needing fully planned/written along with most others needing recorded and edited.</p><p>I want to just take a brief moment to talk about what will follow. Once I've got my Let's Learn AdonisJS 7 series fully completed and released, I'll be taking at least a 90 day seasonal break. And, just to be very clear, this is just a break, I'm not quitting Adocasts! I've actually been kind of hesitant to post this, but here we go.</p><h3>Why the Break? (Part One: It's Me)</h3><p>Building Adocasts alongside a full-time career has been a rewarding journey, and I'm thankful for each and every one of you who've supported me and watched my lessons along the way. I've spent a lot of time these past couple of months reflecting on something new I've realized about myself. I've also been reflecting on the time commitment Adocasts takes, how that time impacts my life, and the trajectory of learning in the face of AI.</p><p>Over the past couple of months I've become aware that I strongly fit the profile of someone with <a target="_blank" rel="nofollow noopener noreferrer" href="https://pmc.ncbi.nlm.nih.gov/articles/PMC2695286/">Aspergers Syndrome, an Autism Spectrum Disorder (ASD)</a>. I'm currently debating whether I want to go through the process of an official diagnosis, but from what I've read it's a time consuming and expensive process without a whole lot of benefits (beyond the certainty of knowing). Regardless of a diagnosis or not, I'm hoping some of the frameworks found to help those with a diagnosis can help me as well. It also helps answer some long standing questions I've always had about myself too.</p><p>Among other things, I've always:</p><ul><li><p>Felt different from everyone else and always felt like I was acting my way through social situations. Following procedures and expectations while others seem to naturally flow through dialog.</p></li><li><p>Been a super quiet person who prefers being alone.</p></li><li><p>Get extremely drained from any social gathering - any!</p></li><li><p>Get stressed if rules or social norms are being deviated</p></li><li><p>Felt like there is a layer of encryption between my mind and body</p></li><li><p>Had a tendency to give short one or two word answers to questions in conversation.</p></li><li><p>Rehearsed for the smallest of things... My low-stakes weekly standup at work, yep! Gotta script that out and say it til it's smooth.</p></li><li><p>Lived by both large and small routines and when they're broken it drastically upsets my day and mood</p></li><li><p>Swayed back and forth while standing, shaked my knees when sitting, and cracked my fingers when distressed, etc</p></li></ul><p>I could go on, but I already feel like I'm sharing too much. Most applicable here is that I realized coding is my special interest. Unlike most things in life, it came naturally to me and sunk me in immediately. I've always hated the fact that it's my job because it's something I have fun doing. It being a job sucks that fun away leaving just the time after work when I'm already tired. I have found that working with things I don't get to use at work brings that joy and experimentation back though.</p><p>Adocasts started as a branch of that joy I found in my post-work time. However, over the past couple of years I've inadvertently slowly turned this fun hobby portion of my special interest into a second job. That shift, started sneakily depleting me because now my special interest is all work and no play. I just haven't had the energy to work on or explore new things outside of work and the requirements needed to push new content for Adocasts. I have had quiet periods where I haven't posted lessons, but trust me, preparing them takes a lot of time, especially on a tired mind.</p><p>So, I've decided forcing myself to take a break is the best path forward. I need to do a hard reset on my routine as it relates to Adocasts and figure out what works for me and what I would like to do to "spark joy" back into my interest so it isn't all work.</p><h3>Why the Break? (Part Two: It's AI)</h3><p>There's another edge to this sword, called AI. We all know AI is making waves through our industry and siphoning eyes from sites to boot. Adocasts traffic is down*, revenue is down, and more and more people mention they're only reaching out for help after AI let them down. No judgement here, It makes sense! AI is quick, mostly free, and easily accessible plus you don't need to dig through site after site to find what you're after.</p><p>I, personally, am not at a point where I trust AI in unsupervised situations. It's just too unpredictable for me: often misunderstanding scope, referencing outdated information, and obfuscating it's sources. It's great for certain tasks, but I don't think learning is one of them at this time. If anything, it can sew more confusion than help in the context of learning. Obviously though, my take there is biased because it directly impacts Adocasts.</p><p>Regardless, even without AI, long-form ways of learning like I provide are on a downward trajectory. So, I want to take a portion of this time away to think about forward looking possibilities here. I don't know if there's an answer to be had, but I think stepping away for a brief bit will help bring clarity with envisioning a pathway forward.</p><p>If you have any thoughts, ideas, or know of anyone doing different things in this regard, I would absolutely love to hear them below in the comments!</p><h3>What This Means</h3><p>First, I again want to reiterate I'll be completing the Let's Learn AdonisJS 7 series before I take this break. Once that is done, I'll be going on at least a 90 day break. During this break I won't be releasing or planning any new content. I'll also be on light duty with social media and support, so responses may be slower than usual. I'll be using this time to reset myself and think about future possibilities to bring into Adocasts.</p><p>Effective today, I will be removing the "Forever" plan as an Adocasts Plus purchase option. If you are a current Forever member, fear not, nothing is changing for you! I'm simply closing this tier to new members until after my break to try and remove the mental load of having a "forever" commitment. I'm still committed, merely just doing some mental jiu-jitsu for myself there.</p><p>If you're an annual subscriber, I get that this is a deviation from my traditional release pattern and want to be as fair as possible. If this upcoming change in release frequency doesn't work for you, please reach out to me (<a target="_blank" rel="nofollow noopener noreferrer" href="mailto:tom@adocasts.com">tom@adocasts.com</a>) and I'd be happy to assist with a pro-rated refund for your remaining time.</p><p>Again this isn't an end, just a break to reboot my system, find joy again in Adocasts, and find a pathway forward with the state of AI in mind. Thank you so much for being a part of this community and for your understanding as I take this step towards a healthier life balance. If you have any thoughts for Adocasts you'd like for me to mull over during my break, please do leave them below!</p><hr><p><em>*Traffic is down - Of late, China alone accounts for 2x the traffic of everywhere else combined, I presume it's bots and have been trying to target it without flagging all of China. So, this is stated with the context of China omitted from traffic.</em></p>]]></content:encoded>
      </item>
      <item>
        <title><![CDATA[Defining Routes & Rendering Views]]></title>
        <link>https://adocasts.com/lessons/defining-routes-and-rendering-views</link>
        <guid>https://adocasts.com/lessons/defining-routes-and-rendering-views</guid>
        <pubDate>Fri, 13 Mar 2026 11:00:00 +0000</pubDate>
        <description><![CDATA[Learn how to define routes and render views in AdonisJS. Understand route definitions, rendering views, and the router service.]]></description>
        <content:encoded><![CDATA[<p>Routes are defined within the <a target="_blank" rel="nofollow noopener noreferrer" href="https://adocasts.com/lessons/lifecycle-and-project-structure-tour">preload stage of our application's lifecycle</a>, meaning we can find them within our&nbsp;<code>start</code>&nbsp;directory, specifically within the&nbsp;<code>routes.ts</code>&nbsp;file.</p><p>The Hypermedia Starter Kit gave us a few predefined routes.</p><pre><code class="language-typescript">// start/routes.ts
/*
|--------------------------------------------------------------------------
| Routes file
|--------------------------------------------------------------------------
|
| The routes file is used for defining the HTTP routes.
|
*/

import { controllers } from '#generated/controllers'
import { middleware } from '#start/kernel'
import router from '@adonisjs/core/services/router'

router.on('/').render('pages/home').as('home')

router
  .group(() =&gt; {
    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())

router
  .group(() =&gt; {
    router.post('logout', [controllers.Session, 'destroy'])
  })
  .use(middleware.auth())</code></pre><p>First, it's importing the&nbsp;<code>router</code>&nbsp;container service. This is a singleton of AdonisJS's router, meaning the same single instance of this service is used every time it's imported for the duration of our server's life.</p><p>Several AdonisJS batteries export <a target="_blank" rel="nofollow noopener noreferrer" href="http://docs.adonisjs.com/guides/concepts/container-services">service containers</a> for convenience, and can be noted by the&nbsp;<code>/services/</code>&nbsp;import path.</p><p>With this&nbsp;<code>router</code>, we can define route definitions. Route definitions are paths we define for our application to handle. For example, when we booted our application in lesson 1.2, we saw a landing page. This is defined by the&nbsp;<code>router.on('/')</code>&nbsp;definition. The&nbsp;<code>on</code>&nbsp;method is a shorthand syntax that will define a GET route definition for the pattern provided with a few simple handling options, like rendering a page as it's doing here.</p><pre><code class="language-ts">// start/routes.ts
  router
    .on('/')  // defines GET: / route definition for the pattern '/'
    .render('pages/home') // handles that route by rendering the home page
    .as('home') // names the route (more on this later)</code></pre><p>In addition to the&nbsp;<code>on</code>&nbsp;shorthand method, each HTTP Verb has its own method that allows us fine-grain control over how to handle the route. Each HTTP Verb has a purpose.</p><ul><li><p>GET is for rendering pages and fetching information</p></li><li><p>POST is for creating records and sending general payloads</p></li><li><p>PUT is for updating a record (multiple properties)</p></li><li><p>PATCH is for a targeted record update (singular properties)</p></li><li><p>DELETE is for deleting records</p></li></ul><h2>Defining a Route</h2><p>Let's start by creating a page of our own to show our application's terms of use, a page almost every application has.</p><pre><code class="language-ts">router.on('/terms').render('pages/terms')</code></pre><p>With this, we're saying that when we request the&nbsp;<code>/terms</code>&nbsp;pattern, we should render the page within&nbsp;<code>resources/views/pages/terms.edge</code>. When rendering, AdonisJS will automatically look within&nbsp;<code>resources/views</code>&nbsp;so we only need to specify the path from there. Now, we haven't created this page yet, so when we request this route, we'll be met with an error screen.</p><p>This error screen is Youch, an informative and developer-friendly error screen to tell us exactly what error we got and a stack trace to help track it down. This screen will only be displayed when our application is in development mode.</p><pre><code class="language-txt">Error: Cannot resolve "/../lets-learn-adonisjs-7/resources/views/pages/terms.edge". Make sure the file exists</code></pre><p>In this case, it's telling us it cannot resolve the page we've told it to render. Let's fix this by creating this page!</p><h2>Creating Views</h2><p>AdonisJS has its own templating engine called EdgeJS, which uses&nbsp;<code>.edge</code>&nbsp;as its file extension and the AdonisJS Extension Pack we installed in Module 1 adds support for EdgeJS to Visual Studio Code.</p><pre><code class="language-bash">touch resources/views/pages/terms.edge</code></pre><p>Then we can use emmet to quickly stub HTML5 markup with&nbsp;<code>html:5</code>&nbsp;to get:</p><pre><code class="language-edge">// resources/views/pages/terms.edge
&lt;!DOCTYPE html&gt;
&lt;html lang="en"&gt;
&lt;head&gt;
  &lt;meta charset="UTF-8"&gt;
  &lt;meta name="viewport" content="width=device-width, initial-scale=1.0"&gt;
  &lt;title&gt;Document&lt;/title&gt;
&lt;/head&gt;
&lt;body&gt;
  
&lt;/body&gt;
&lt;/html&gt;</code></pre><p>On our body, let's just add some simple text!</p><pre><code class="language-edge">// resources/views/pages/terms.edge
&lt;!DOCTYPE html&gt;
&lt;html lang="en"&gt;
&lt;head&gt;
  &lt;meta charset="UTF-8"&gt;
  &lt;meta name="viewport" content="width=device-width, initial-scale=1.0"&gt;
  &lt;title&gt;Document&lt;/title&gt;
&lt;/head&gt;
&lt;body&gt;
++  &lt;h1&gt;Terms of Service&lt;/h1&gt;
++  &lt;p&gt;This is the terms of service page.&lt;/p&gt;
&lt;/body&gt;
&lt;/html&gt;</code></pre><p>Now, if we refresh our browser, instead of being met by Youch with an error screen, we see our page!</p><p>Let's add another view, this time, using the Ace CLI!</p><pre><code class="language-bash">node ace make:view pages/challenges/index
# DONE:    create resources/views/pages/challenges/index.edge</code></pre><p>Our naming here of&nbsp;<code>challenges/index</code>&nbsp;follows resourceful naming conventions, where "challenge" is our resource. Resourceful naming has conventions for each HTTP operation needed to list, show, create, update, and delete a resource.</p><ul><li><p><code>GET /challenges</code>&nbsp;uses the name&nbsp;<code>index</code>&nbsp;and renders a list of items for the resource</p></li><li><p><code>GET /challenges/1</code>&nbsp;uses the name&nbsp;<code>show</code>&nbsp;and renders a single item, "1" here serves as an identifier.</p></li><li><p><code>GET /challenges/create</code>&nbsp;uses the name&nbsp;<code>create</code>&nbsp;and renders a form to create an item</p></li><li><p><code>POST /challenges</code>&nbsp;uses the name&nbsp;<code>store</code>&nbsp;and handles the form to create an item</p></li><li><p><code>GET /challenges/1/edit</code>&nbsp;uses the name&nbsp;<code>edit</code>&nbsp;and renders a form to edit an item</p></li><li><p><code>PUT /challenges/1</code>&nbsp;uses the name&nbsp;<code>update</code>&nbsp;and handles the form to update an item</p></li><li><p><code>DELETE /challenges/1</code>&nbsp;uses the name&nbsp;<code>destroy</code>&nbsp;and handles the deletion of an item</p></li></ul><p>We'll see this naming convention used in both our route definitions and view names. Keeping to the convention helps keep things predictable and easily understandable.</p><p>Okay, let's add some simple HTML5 markup again, using <code>html:5</code>, with some basic text noting which page we're on.</p><pre><code class="language-edge">// resources/views/pages/challenges/index.edge
&lt;!DOCTYPE html&gt;
&lt;html lang="en"&gt;
&lt;head&gt;
  &lt;meta charset="UTF-8"&gt;
  &lt;meta name="viewport" content="width=device-width, initial-scale=1.0"&gt;
  &lt;title&gt;Document&lt;/title&gt;
&lt;/head&gt;
&lt;body&gt;
  &lt;h1&gt;Challenges&lt;/h1&gt;
&lt;/body&gt;
&lt;/html&gt;</code></pre><h2>Rendering Views</h2><p>Finally, let's define a route for this view so we can actually request and render it. This time, we'll use the&nbsp;<code>get</code>&nbsp;method. Unlike the&nbsp;<code>on</code>&nbsp;shorthand, this accepts a second argument allowing us fine-grained control over how the route should be handled via a route handler.</p><pre><code class="language-ts">// start/routes.ts
router.get('/challenges', async (ctx) =&gt; {
  
})</code></pre><p>The route handler is provided an HttpContext, contextual information about the request. It's with this that we can specify what page to render, gain access to request and response properties, and a lot more. As we continue through this series, we'll get more acquainted with the HttpContext, for now though, let's use it to render our page.</p><pre><code class="language-ts">// start/routes.ts
router.get('/challenges', async (ctx) =&gt; {
++  return ctx.view.render('pages/challenges/index')
})</code></pre><p>Our render method is chained off the&nbsp;<code>view</code>&nbsp;property within our HttpContext. This method is the same&nbsp;<code>render</code>&nbsp;method we used with our shorthand; however, this time you'll notice we get some convenient autocomplete options pulling from pre-existing pages within our application.</p><p>Okay, great with that saved, we should now be able to head to&nbsp;<code>/challenges</code>&nbsp;in our browser to see this page rendered out!</p><p>Before we move on, in case you're concerned that we have to manually refresh our browser to see our changes, don't worry! This is simply because our browser isn't hooked into our Vite dev server. We can fix this by applying our starter kit's layout component to the page. This layout comes pre-packaged with the HTML:5 boilerplate we already have on our page in addition to an&nbsp;<code>@vite</code>&nbsp;tag pointing to our CSS and JS file. It's this&nbsp;<code>@vite</code>&nbsp;tag that wires our browser up to our dev server to get our browser to automatically pick up changes we make in-code. There's a lot going on here; we'll slowly get comfortable with all of this. For now, we can swap our HTML5 boilerplate with our layout.</p><pre><code class="language-edge">// resources/views/pages/challenges/index.edge
@layout()

  &lt;h1&gt;Challenges&lt;/h1&gt;

@end</code></pre><p>Anything in between the layout start and end tag is ultimately rendered out via the layout's main slot, again... more on that later!</p><pre><code class="language-edge">// resources/views/components/layout.edge
&lt;!DOCTYPE html&gt;
&lt;html lang="en-us"&gt;
  &lt;head&gt;
    &lt;meta charset="utf-8" /&gt;
    &lt;meta name="viewport" content="width=device-width, initial-scale=1" /&gt;
    &lt;title&gt;
      AdonisJS - A fully featured web framework for Node.js
    &lt;/title&gt;
    @vite(['resources/css/app.css', 'resources/js/app.js'])
    @stack('dumper')
  &lt;/head&gt;
  &lt;body&gt;
    @include('partials/header')
    &lt;main&gt;
      @include('partials/flash_alerts')
      {{{ await $slots.main() }}}
    &lt;/main&gt;
  &lt;/body&gt;
&lt;/html&gt;</code></pre><p>So, if we add a paragraph to our challenges page...</p><pre><code class="language-edge">// resources/views/pages/challenges/index.edge
@layout()

  &lt;div&gt;
    &lt;h1&gt;Challenges&lt;/h1&gt;
    &lt;p&gt;Welcome to the challenges page.&lt;/p&gt;
  &lt;/div&gt;

@end</code></pre><p>We'll see this automatically applied in our browser without the need to refresh manually!</p>]]></content:encoded>
      </item>
      <item>
        <title><![CDATA[EdgeJS View & Tag Syntax]]></title>
        <link>https://adocasts.com/lessons/view-and-tag-syntax</link>
        <guid>https://adocasts.com/lessons/view-and-tag-syntax</guid>
        <pubDate>Fri, 13 Mar 2026 11:00:00 +0000</pubDate>
        <description><![CDATA[Learn all about EdgeJS templating with interpolation, conditionals, loops, variables, and tags. We'll also talk about comments and escaping interpolation and HTML markup.]]></description>
        <content:encoded><![CDATA[<h2>Interpolation</h2><p>We've already learned a little about <a target="_blank" rel="nofollow noopener noreferrer" href="https://edgejs.dev/docs/interpolation">interpolation</a> and tags with EdgeJS, but we're going to drive it home here. For starters, we can do more than merely access state with interpolation; we can perform JavaScript expressions as well.</p><p>For example, if we wanted to show the total number of points available from our challenges, we could evaluate that aggregation within an interpolation!</p><pre><code class="language-edge">// resources/views/pages/challenges/index.edge
@layout()

  &lt;div&gt;
    &lt;h1&gt;Challenges&lt;/h1&gt;
    &lt;p&gt;Welcome to the challenges page.&lt;/p&gt;

++    &lt;div&gt;
++      Total Points Available: 
++      {{ challenges.reduce((total, challenge) =&gt; total + challenge.points, 0) }}
++    &lt;/div&gt;
  &lt;/div&gt;

@end</code></pre><p>Note these interpolations can span multiple lines as well if you need to do something like a bracketed callback or if you're performing a longer ternary.</p><pre><code class="language-edge">// resources/views/pages/challenges/index.edge
@layout()

  &lt;div&gt;
    &lt;h1&gt;Challenges&lt;/h1&gt;
    &lt;p&gt;Welcome to the challenges page.&lt;/p&gt;

    &lt;div&gt;
      Total Points Available: 
++      {{ 
++        challenges.reduce((total, challenge) =&gt; {
++          return total + challenge.points
++        }, 0) 
++      }}
    &lt;/div&gt;
  &lt;/div&gt;

@end</code></pre><h2>Conditionals &amp; Loops</h2><p>EdgeJS supports <a target="_blank" rel="nofollow noopener noreferrer" href="https://edgejs.dev/docs/conditionals">conditionals</a> and <a target="_blank" rel="nofollow noopener noreferrer" href="https://edgejs.dev/docs/loops">loops</a> as well via tags, so if we wanted to loop over our challenges, we could use&nbsp;<code>@each</code>&nbsp;to do just that!</p><pre><code class="language-edge">// resources/views/pages/challenges/index.edge
@layout()

  &lt;div&gt;
    &lt;h1&gt;Challenges&lt;/h1&gt;
    &lt;p&gt;Welcome to the challenges page.&lt;/p&gt;

    &lt;div&gt;
      Total Points Available: 
      {{ 
        challenges.reduce((total, challenge) =&gt; {
          return total + challenge.points
        }, 0) 
      }}
    &lt;/div&gt;

++    &lt;ul&gt;
++      @each(challenge in challenges)
++        &lt;li&gt;
++          &lt;h3&gt;{{ challenge.text }}&lt;/h3&gt;
++          &lt;p&gt;Points: {{ challenge.points }}&lt;/p&gt;
++        &lt;/li&gt;
++      @endeach
++    &lt;/ul&gt;
  &lt;/div&gt;

@end</code></pre><p>Here&nbsp;<code>@each</code>&nbsp;starts the loop and&nbsp;<code>@endeach</code>&nbsp;ends the loop. In between, we have access to the individual challenge currently being looped over. Now, as mentioned previously, tags must be on a line of their own. So, something like the below is invalid!</p><pre><code class="language-edge">&lt;ul&gt; @each(challenge in challenges) {{ challenge.text }} @endeach &lt;/ul&gt;</code></pre><p>If we wanted to show a message in the event that we don't have any challenges, we can use a conditional!</p><pre><code class="language-edge">// resources/views/pages/challenges/index.edge
@layout()

  &lt;div&gt;
    &lt;h1&gt;Challenges&lt;/h1&gt;
    &lt;p&gt;Welcome to the challenges page.&lt;/p&gt;

    &lt;div&gt;
      Total Points Available: 
      {{ 
        challenges.reduce((total, challenge) =&gt; {
          return total + challenge.points
        }, 0) 
      }}
    &lt;/div&gt;

++    @if (!challenges.length)
++      &lt;p&gt;No challenges available.&lt;/p&gt;
++    @else
++      &lt;p&gt;Below are our available challenges:&lt;/p&gt;
++    @endif

    &lt;ul&gt;
      @each(challenge in challenges)
        &lt;li&gt;
          &lt;h3&gt;{{ challenge.text }}&lt;/h3&gt;
          &lt;p&gt;Points: {{ challenge.points }}&lt;/p&gt;
        &lt;/li&gt;
      @endeach
    &lt;/ul&gt;
  &lt;/div&gt;

@end</code></pre><p>We can also utilize&nbsp;<code>@elseif()</code>&nbsp;if needed here as well, and we could use&nbsp;<code>@unless</code>&nbsp;instead of an&nbsp;<code>@if</code>&nbsp;as an alternative.</p><pre><code>@unless (challenges.length)
  &lt;p&gt;No challenges available!&lt;/p&gt;
@endunless</code></pre><p>These native tags all come with supporting named end tags, for example&nbsp;<code>@endif</code>&nbsp;goes with&nbsp;<code>@if</code>. However, if you prefer,&nbsp;<code>@end</code>&nbsp;can be used as a consistent means to end any tag. I tend to prefer the named end tags as it signals what exactly it is we're ending, but to each their own.</p><h2>Variables</h2><p>Beyond state, we can also <a target="_blank" rel="nofollow noopener noreferrer" href="https://edgejs.dev/docs/templates_state#inline-variables">declare variables</a> within our templates as well. We can use the&nbsp;<code>@let</code>&nbsp;tag to declare a new block-level variable and&nbsp;<code>@assign</code>&nbsp;to update a variable's value. This can be used in conjunction with conditionals to do something like the below!</p><pre><code class="language-edge">// resources/views/pages/challenges/index.edge
++@let(availablePoints = challenges.reduce((total, challenge) =&gt; total + challenge.points, 0))

++@if (completedChallenges?.length)
++  @let(completedPoints = completedChallenges.reduce((total, challenge) =&gt; total + challenge.points, 0))
++  @assign(availablePoints = availablePoints - completedPoints)
++@endif

@layout()

  &lt;div&gt;
    &lt;h1&gt;Challenges&lt;/h1&gt;
    &lt;p&gt;Welcome to the challenges page.&lt;/p&gt;

    &lt;div&gt;
++      Total Points Available: {{ availablePoints }}
    &lt;/div&gt;

    @if (!challenges.length)
      &lt;p&gt;No challenges available.&lt;/p&gt;
    @else
      &lt;p&gt;Below are our available challenges:&lt;/p&gt;
    @endif

    &lt;ul&gt;
      @each(challenge in challenges)
        &lt;li&gt;
          &lt;h3&gt;{{ challenge.text }}&lt;/h3&gt;
          &lt;p&gt;Points: {{ challenge.points }}&lt;/p&gt;
        &lt;/li&gt;
      @endeach
    &lt;/ul&gt;
  &lt;/div&gt;

@end</code></pre><h2>Rendering Raw HTML</h2><p>Now, as a means of security, the double curly-braced interpolation will <a target="_blank" rel="nofollow noopener noreferrer" href="https://edgejs.dev/docs/interpolation#escaped-html-output">escape HTML</a>. So, if you find yourself needing to actually render out HTML, you'll want to use three curly braces instead of two! Three won't escape HTML while two will. As we saw in our layout component, the slot is using three. That's because the slot will actually return HTML to be rendered. So, if we were to switch that to just two, we can see exactly how that impacts our page.</p><pre><code class="language-edge">// resources/views/components/layout.edge
&lt;!DOCTYPE html&gt;
&lt;html lang="en-us"&gt;
  &lt;head&gt;
    &lt;meta charset="utf-8" /&gt;
    &lt;meta name="viewport" content="width=device-width, initial-scale=1" /&gt;
    &lt;title&gt;
      AdonisJS - A fully featured web framework for Node.js
    &lt;/title&gt;
    @vite(['resources/css/app.css', 'resources/js/app.js'])
    @stack('dumper')
  &lt;/head&gt;
  &lt;body&gt;
    @include('partials/header')
    &lt;main&gt;
      @include('partials/flash_alerts')
++      {{ await $slots.main() }}
    &lt;/main&gt;
  &lt;/body&gt;
&lt;/html&gt;</code></pre><p>With that, now we're just seeing the HTML that should be rendered as HTML instead being rendered as text content. So, let's undo that to get our HTML back!</p><h2>Escaping Interpolation</h2><p>If we need to actually render double or triple curly braces on our page, we can use an&nbsp;<code>@</code>&nbsp;to <a target="_blank" rel="nofollow noopener noreferrer" href="https://edgejs.dev/docs/interpolation#skipping-curly-braces-from-evaluation">escape the interpolation</a> to instead render the actual curly braces.</p><pre><code class="language-edge">// resources/views/components/layout.edge
&lt;!DOCTYPE html&gt;
&lt;html lang="en-us"&gt;
  &lt;head&gt;
    &lt;meta charset="utf-8" /&gt;
    &lt;meta name="viewport" content="width=device-width, initial-scale=1" /&gt;
    &lt;title&gt;
      AdonisJS - A fully featured web framework for Node.js
    &lt;/title&gt;
    @vite(['resources/css/app.css', 'resources/js/app.js'])
    @stack('dumper')
  &lt;/head&gt;
  &lt;body&gt;
    @include('partials/header')
    &lt;main&gt;
      @include('partials/flash_alerts')
++      @{{{ await $slots.main() }}}
    &lt;/main&gt;
  &lt;/body&gt;
&lt;/html&gt;</code></pre><p>With that, we literally see "{{{ await $slots.main() }}}" printed out on our page, and EdgeJS bypasses the expression altogether. Again, let's undo that to get our slot back to rendering.</p><h2>Comments</h2><p>Finally, EdgeJS supports <a target="_blank" rel="nofollow noopener noreferrer" href="https://edgejs.dev/docs/syntax_specification#comments">comments</a> as well; the syntax is double curly braces with double hyphens.</p><pre><code class="language-edge">// resources/views/components/layout.edge
&lt;!DOCTYPE html&gt;
&lt;html lang="en-us"&gt;
  &lt;head&gt;
    &lt;meta charset="utf-8" /&gt;
    &lt;meta name="viewport" content="width=device-width, initial-scale=1" /&gt;
    &lt;title&gt;
      AdonisJS - A fully featured web framework for Node.js
    &lt;/title&gt;
    @vite(['resources/css/app.css', 'resources/js/app.js'])
    @stack('dumper')
  &lt;/head&gt;
  &lt;body&gt;
    @include('partials/header')
    &lt;main&gt;
      @include('partials/flash_alerts')

      {{-- renders default slot content --}}
      {{{ await $slots.main() }}}
    &lt;/main&gt;
  &lt;/body&gt;
&lt;/html&gt;</code></pre>]]></content:encoded>
      </item>
  </channel>
</rss>