Skip to main content

Lifecycle Hooks

Lifecycle hooks run automatically before or after database operations. In Strapi 5, database lifecycle hooks still exist, but Document Service middleware is usually the better extension point for content workflows because it sees document-level actions such as Draft & Publish and i18n operations.

Document Service middleware​

Document Service middleware intercepts calls on the Document Service API and is the safest place for content-level side effects that should run for create, update, delete, publish, unpublish, and similar document actions.

Registration​

Register all document service middleware in src/index.js (or src/index.ts):

// src/index.js
module.exports = {
register({ strapi }) {
// Register middleware here
strapi.documents.use(async (context, next) => {
// runs for ALL content types and ALL actions
const result = await next();
return result;
});
},
};

The context object​

Every middleware receives a context with:

PropertyTypeDescription
uidstringContent type UID (e.g., api::article.article)
actionstringMethod name such as findOne, findFirst, findMany, create, update, delete, publish, unpublish, discardDraft, count
paramsobjectThe parameters passed to the method (data, filters, populate, etc.)
contentTypeobjectFull content type schema

Database lifecycle hooks​

Database lifecycle hooks are still available when you need to react to lower-level Query Engine operations. Define them beside a content type:

// src/api/article/content-types/article/lifecycles.js
module.exports = {
beforeCreate(event) {
event.params.data.slug = event.params.data.slug || 'draft';
event.state.startedAt = Date.now();
},

afterCreate(event) {
strapi.log.info(`Created article row ${event.result.id}`);
},
};

The event object contains action, model, params, result for after* hooks, and state for sharing data between the matching before* and after* hook. Available events include beforeCreate, afterCreate, beforeCreateMany, afterCreateMany, beforeUpdate, afterUpdate, beforeUpdateMany, afterUpdateMany, beforeDelete, afterDelete, beforeDeleteMany, afterDeleteMany, beforeCount, afterCount, beforeFindOne, afterFindOne, beforeFindMany, and afterFindMany.

You can also subscribe programmatically:

strapi.db.lifecycles.subscribe({
models: ['api::article.article'],

beforeUpdate(event) {
strapi.log.debug(`Updating ${event.model.uid}`);
},
});

Be careful with Draft & Publish and i18n: one Document Service action can trigger several database operations. For example, publishing creates a published row and may delete an old published row. Bulk database lifecycle hooks are not triggered by Document Service methods. Use Document Service middleware when you need one hook per document action.


Practical examples​

Auto-generate slugs before create/update​

// src/index.js
module.exports = {
register({ strapi }) {
strapi.documents.use(async (context, next) => {
if (context.uid !== 'api::article.article') {
return next();
}

if (['create', 'update'].includes(context.action)) {
const title = context.params.data?.title;

if (title) {
context.params.data.slug = title
.toLowerCase()
.replace(/[äöü]/g, (c) => ({ ä: 'ae', ö: 'oe', ü: 'ue' }[c]))
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-|-$/g, '');
}
}

return next();
});
},
};

Audit log: track who changed what​

// src/index.js
const auditableTypes = [
'api::article.article',
'api::page.page',
'api::product.product',
];

module.exports = {
register({ strapi }) {
strapi.documents.use(async (context, next) => {
if (!auditableTypes.includes(context.uid)) {
return next();
}

if (!['create', 'update', 'delete'].includes(context.action)) {
return next();
}

const result = await next();

// Log the change asynchronously (don't block the response)
setImmediate(async () => {
try {
await strapi.documents('api::audit-log.audit-log').create({
data: {
contentType: context.uid,
action: context.action,
documentId: context.params.documentId || result?.documentId,
data: JSON.stringify(context.params.data || {}),
timestamp: new Date().toISOString(),
},
});
} catch (error) {
strapi.log.error('Audit log failed:', error);
}
});

return result;
});
},
};

Validate data before create​

module.exports = {
register({ strapi }) {
strapi.documents.use(async (context, next) => {
if (context.uid === 'api::product.product' && context.action === 'create') {
const { price, name } = context.params.data || {};

if (!name || name.trim().length < 2) {
throw new Error('Product name must be at least 2 characters');
}

if (price !== undefined && price < 0) {
throw new Error('Price cannot be negative');
}
}

return next();
});
},
};

Cache invalidation after mutations​

const Redis = require('ioredis');
const redis = new Redis(process.env.REDIS_URL);

async function deleteByPattern(pattern) {
let cursor = '0';

do {
const [nextCursor, keys] = await redis.scan(cursor, 'MATCH', pattern, 'COUNT', 100);
cursor = nextCursor;

if (keys.length > 0) {
await redis.del(...keys);
}
} while (cursor !== '0');
}

module.exports = {
register({ strapi }) {
strapi.documents.use(async (context, next) => {
const result = await next();

// Invalidate cache after any write operation
if (['create', 'update', 'delete', 'publish', 'unpublish'].includes(context.action)) {
const pattern = `strapi:${context.uid}:*`;

await deleteByPattern(pattern);
strapi.log.debug(`Invalidated cache entries for ${context.uid}`);
}

return result;
});
},
};

Send email on publish​

module.exports = {
register({ strapi }) {
strapi.documents.use(async (context, next) => {
const result = await next();

if (
context.uid === 'api::article.article' &&
context.action === 'publish'
) {
setImmediate(async () => {
try {
const article = await strapi.documents('api::article.article').findOne({
documentId: result.documentId,
populate: ['author'],
});

if (article?.author?.email) {
await strapi.plugins['email'].services.email.send({
to: article.author.email,
subject: `Your article "${article.title}" has been published!`,
html: `<p>Your article is now live. <a href="https://example.com/articles/${article.slug}">View it here</a>.</p>`,
});
}
} catch (err) {
strapi.log.error('Publish notification failed:', err);
}
});
}

return result;
});
},
};

Compute fields after create/update​

module.exports = {
register({ strapi }) {
strapi.documents.use(async (context, next) => {
const result = await next();

if (
context.uid === 'api::article.article' &&
['create', 'update'].includes(context.action)
) {
const content = result?.content || '';
const wordCount = content.split(/\s+/).filter(Boolean).length;
const readingTime = Math.ceil(wordCount / 200);

// Update silently without re-triggering middleware
await strapi.db.query('api::article.article').update({
where: { documentId: result.documentId },
data: { readingTime, wordCount },
});
}

return result;
});
},
};

Multiple middleware: execution order​

Middleware runs in the order it is registered. Each one wraps the next:

module.exports = {
register({ strapi }) {
// Middleware 1 - runs first (before) and last (after)
strapi.documents.use(async (context, next) => {
strapi.log.debug('Middleware 1: before');
const result = await next();
strapi.log.debug('Middleware 1: after');
return result;
});

// Middleware 2 - runs second (before) and second-to-last (after)
strapi.documents.use(async (context, next) => {
strapi.log.debug('Middleware 2: before');
const result = await next();
strapi.log.debug('Middleware 2: after');
return result;
});
},
};

// Output order:
// Middleware 1: before
// Middleware 2: before
// (database operation)
// Middleware 2: after
// Middleware 1: after

Common pitfalls​

PitfallProblemFix
Not returning next()Breaks the entire middleware chainAlways return next() or return await next()
Blocking side effectsSlow responses if email/webhook is synchronousUse setImmediate() for fire-and-forget tasks
Infinite loopsMiddleware triggers another update which triggers itselfUse strapi.db.query() for silent updates
No UID guardMiddleware runs for all content typesCheck context.uid early and return next()
Heavy computationsSlows down every matching operationMove to a queue or async job

See also​