Content lifecycle
Every content item in Forge has a lifecycle. It is not optional, and it cannot be disabled. This is what guarantees that draft content never leaks to the public — not by convention, but by architecture.
The four states
Draft → Published
Draft → Scheduled → Published
Published → Archived
| State | forge.Status constant | Visible to public |
|---|---|---|
| Draft | forge.Draft | No — 404 |
| Scheduled | forge.Scheduled | No — 404 |
| Published | forge.Published | Yes |
| Archived | forge.Archived | No — 410 Gone |
Why 410 for archived, not 404? A 410 Gone response tells search engines the content was intentionally removed. Google de-indexes 410 pages significantly faster than 404 pages. For a CMS, this is almost always what you want.
What Forge enforces automatically
| Draft | Scheduled | Archived | Published | |
|---|---|---|---|---|
| Public GET | 404 | 404 | 410 | 200 |
| Sitemap | no | no | no | yes |
| RSS feed | no | no | no | yes |
| llms.txt / AIDoc | no | no | no | yes |
<meta robots> | noindex | noindex | noindex | index |
| Author (own content) | visible | visible | visible | visible |
| Editor+ | visible | visible | visible | visible |
None of this requires configuration. Forge applies it to every registered content module.
Transitions via the HTTP API
Content transitions happen through standard PUT requests:
# Publish a draft
PUT /posts/my-post
{"status": "published"}
# Schedule for future publication
PUT /posts/my-post
{"status": "scheduled", "scheduled_at": "2026-06-01T09:00:00Z"}
# Archive a published post
PUT /posts/my-post
{"status": "archived"}
PublishedAt is set automatically when a post transitions to Published — whether immediately or via the scheduler. You cannot set it directly.
Scheduled publishing
Forge runs an internal scheduler. No external cron or queue required. When scheduled_at arrives, Forge:
1. Transitions the item to Published 2. Sets PublishedAt 3. Fires AfterPublish on the signal bus 4. Regenerates the sitemap and RSS feed
The scheduler is adaptive — it wakes up earlier when items are close to their scheduled time and backs off when nothing is due.
Slug stability and redirects
Forge tracks every URL a content item has ever had. Renaming a slug or changing a module prefix generates a 301 redirect automatically. You never need to manage redirects by hand.
| Event | Old URL response |
|---|---|
| Slug renamed | 301 → new URL |
| Module prefix changed | 301 → new prefix |
| Item archived | 410 Gone |
| Item deleted | 410 Gone |
Subscribing to lifecycle events
Use app.OnSignal to react to lifecycle transitions in your application code:
app.OnSignal(forge.AfterPublish, func(ctx context.Context, ev forge.SignalEvent) error {
log.Printf("published: %s — %s", ev.Type, ev.URL)
return nil
})
app.OnSignal(forge.AfterArchive, func(ctx context.Context, ev forge.SignalEvent) error {
return cache.Invalidate(ev.URL)
})
All seven lifecycle signals are available:
| Signal | Constant | Fired when |
|---|---|---|
| After create | forge.AfterCreate | Item first persisted (any status) |
| After update | forge.AfterUpdate | Fields changed |
| After publish | forge.AfterPublish | Transitions to Published |
| After unpublish | forge.AfterUnpublish | Leaves Published |
| After archive | forge.AfterArchive | Transitions to Archived |
| After schedule | forge.AfterSchedule | Transitions to Scheduled |
| After delete | forge.AfterDelete | Permanently deleted |
Handlers are async, do not block HTTP responses, and run with a 100 ms timeout. Multiple handlers per signal are supported. See Signal bus for full details.
Module-level hooks (Before signals)
For hooks that need to intercept or modify a write operation before it completes, use forge.On[T] as a module option. Before handlers can return an error to abort the operation.
forge.NewModule((*Post)(nil),
forge.At("/posts"),
forge.Repo(repo),
// Runs before create — can abort by returning an error
forge.On(forge.BeforeCreate, func(ctx forge.Context, p *Post) error {
p.Author = ctx.User().Name
return nil
}),
// Runs before publish — can abort
forge.On(forge.BeforeUpdate, func(ctx forge.Context, p *Post) error {
if p.Status == forge.Published && p.Cover.URL == "" {
return forge.Err("cover", "required when publishing")
}
return nil
}),
)
| Hook type | API | When | Can abort? | Receives |
|---|---|---|---|---|
| Before (module-level) | forge.On[T] | Before DB write | Yes | *T (typed) |
| After (bus) | app.OnSignal | After DB write | No | SignalEvent |
| After (module-level typed) | forge.On[T] | After DB write | No | *T (typed) |
Outbound webhooks
Webhook delivery is built on top of lifecycle signals. Register an HTTPS endpoint with App.Webhooks and Forge will POST a signed JSON payload on every matching event. See Webhooks for setup and payload format.