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.
If you'll recall, we were mimicking create, read, and update operations within our ChallengesController using an in-memory array. Let's work on transitioning that to use our database instead.
Creating Records
At the moment, we're just pushing a new challenge into an array defined above our controller's class.
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') } // ... }Copied!
- app
- controllers
- challenges_controller.ts
What we want to do instead, to use our database, is import our Challenge 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 Challenge model we're creating a representation of a new row in our challenges table.
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') } // ... }Copied!
- app
- controllers
- challenges_controller.ts
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.
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') } // ... }Copied!
- app
- controllers
- challenges_controller.ts
When we call await challenge.save() that is where the current values are held on our challenge 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!
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') } // ... }Copied!
- app
- controllers
- challenges_controller.ts
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 create method. The create method essentially does exactly what we previously had, just in a smaller package, allowing us to create a challenge with just:
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') } // ... }Copied!
- app
- controllers
- challenges_controller.ts
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.
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') } // ... }Copied!
- app
- controllers
- challenges_controller.ts
Note, there's also a createMany() 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.
const challenges = await Challenge.createMany([ challengeDataOne, challengeDataTwo ])Copied!
An Aside About Foreign Keys & REPL
One issue, though, our challenges table has a not null creator_id column within it. So, if we were to attempt to create a challenge, we'll get:
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_idWe also can't just plop any ol' number in as a creator_id because we have a foreign key constraint on this column requiring it to hold a value existing within our users.id.
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') }Copied!
If we were to try, we'd get:
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 failedThankfully, 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 role_id, but our roles table is empty at the moment.
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 node ace repl.
node ace repl # Type ".ls" to a view list of available context methods/properties # > (js)Copied!
If you type .ls and hit enter, you'll see a list of all available methods & properties.
> (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: {}Copied!
What we're after is the loadModels() method. This will make our models available via a models property.
await loadModels() # recursively reading models from "app/models" # Loaded models module. You can access it using the "models" variable # > (js) undefinedCopied!
This will execute the method and show us what it returned, which was undefined. We can then use the loaded models to create a new role, same as we did in our controller.
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 } # }Copied!
As you can see, our create() 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.
> (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 } # }Copied!
A few things to note here.
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.
Our password was entered as
something, but was persisted into the database as$scrypt$n=163.... This is done automatically for us via an@beforeSave()hook added onto ourUsermodel by AdonisJS's authentication. We'll discuss this more later in the series.
Fantastic! We've now got our role and user, so we can exit our REPL session by typing .exit and hitting enter. Let's then boot our server back up and try creating a challenge once more.
If all went according to plan, we should see our "Challenge created successfully" toast message and be redirected back to challenges.index. However, we're still reading from our in-memory array so we don't see it within our list. Let's fix that next.
Reading Data
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.
Starting simple, we can grab everything in our challenges table using the static all() method. I'll call our variable list here because challenges is still in use.
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 }) } // ... }Copied!
- app
- controllers
- challenges_controller.ts
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.
When trying to get at a single record, we have a few static options. First, we can find() a row by providing it's id as the argument.
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 }) } // ... }Copied!
- app
- controllers
- challenges_controller.ts
This will attempt to find a challenge with the provided ID and return null if one cannot be found. We can also findBy(), providing a column we want to search against in the first argument and the value in the second.
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 }) } // ... }Copied!
- app
- controllers
- challenges_controller.ts
Note, this can also be written as await Challenge.findBy({ id: params.id }). 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,
const challenges = await Challenge.findMany([1, 2, 3])Copied!
Both, find() and findBy() 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.
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 }) } // ... }Copied!
- app
- controllers
- challenges_controller.ts
Updating Data
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.
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 }) } // ... }Copied!
- app
- controllers
- challenges_controller.ts
Similar to the create() method, there's also a shorthand for updating, called updateOrCreate(). 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.
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 }) } // ... }Copied!
- app
- controllers
- challenges_controller.ts
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.
Again, like create(), there's also an updateOrCreateMany() version. It accepts an array of data to update or create with.
const challenges = await Challenge.updateOrCreate({ text: 'Test' }, [dataOne, dataTwo])Copied!
Deleting Records
Deleting records in AdonisJS is rather straightforward; we just need to query for the record and call it's delete() method. Once run, it'll delete that record from our database.
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') } }Copied!
- app
- controllers
- challenges_controller.ts
Next, let's add a form on our challenges.show 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.
@layout() <div class="hero"> <h1>{{ challenge.text }}</h1> <p>Points: {{ challenge.points }}</p> <a href="{{ editUrl }}" class="button">Edit Challenge</a> <button type="submit" form="destroy" class="button destructive">Delete</button> </div> @!form({ id: 'destroy', route: 'challenges.destroy', routeParams: { id: challenge.id }, method: 'DELETE' }) @endCopied!
- resources
- views
- pages
- challenges
- show.edge
With that added, if we click our "Delete" button, we should see our success toast and be redirected back to the challenges.index page! However, one thing to note. When we delete, we need to ensure our foreign key constraints are enforced; otherwise, the delete will fail.
We could do this by cascading the delete within our database. That can be done via the migration, like below.
import { BaseSchema } from '@adonisjs/lucid/schema' export default class extends BaseSchema { protected tableName = 'challenge_user' async up() { this.schema.createTable(this.tableName, (table) => { 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) } }Copied!
- database
- migrations
- xxxxxx_create_challenge_users_table.ts
With this, when we delete a challenge any challenge_user records using that challenge's ID will also automatically be deleted.
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 related() method on our model instance and detach or delete related records.
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') } }Copied!
- app
- controllers
- challenges_controller.ts
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.