[6.x] Generate the title as you type when using a title format - #15170
[6.x] Generate the title as you type when using a title format#15170duncanmcclean wants to merge 4 commits into
Conversation
… `title_format` `EntryTitleFormatController` builds an entry from the submitted publish values and returns `autoGeneratedTitle()`, so the control panel can show the title before the entry has been saved. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The `slug` fieldtype already generates from `title`, but with a `title_format` the title stayed empty until the entry was saved, leaving nothing to slugify. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
jasonvarga
left a comment
There was a problem hiding this comment.
Nice UX improvement, and I like that it reuses autoGeneratedTitle() rather than trying to reimplement title formats in JS. I dug into whether spinning up throwaway entries was a problem and I'm satisfied it isn't — nothing is saved, no events fire, no Stache writes, no revisions, and augmentation stays lazy so only the fields the format actually references get resolved. Same shape as EntryPreviewController. So the approach is sound and I don't want it reworked.
There's one thing that does need fixing before this can go in.
The submitted slug can be stale, and it wins
Live Preview gets away with building a throwaway entry because its output renders into an iframe and dies. Here the generated title and slug land in the actual form and get submitted, so they become stored data — and the client's copy lags the keystrokes by quite a lot:
- 300ms title debounce → title-format round trip →
setFieldValue('title') - →
Slugifywatcher →Slug.jsasync slugifier, debounced another 300ms →/sluground trip - →
SlugFieldtype→updateDebounced, 150ms
That's roughly 750ms–1s from last keystroke to the slug landing in the container values. SavePipeline only waits UPDATE_DEBOUNCE_MS + 1 (151ms) before reading them.
So: type "Michael" into first_name, pause, type "Aerni" into last_name, hit save ~400ms later. values.slug is still michael from the first pause, and resolveSlug() returns $request->slug verbatim. The entry saves with the title "Michael Aerni" and the permalink /michael. Silently, and permanently.
Before this PR the server got this right by construction, because $request->slug was always empty and $request->title always null on an auto-title collection, so it always derived the slug from the full server-side autoGeneratedTitle(). Both branches of resolveSlug() are now reachable with stale client values. Collections with autosaveInterval set have the same exposure.
Suggested fix
The title half is free: Entry::save() already overwrites the title with autoGeneratedTitle() for these collections, so the submitted title is only influencing resolveSlug()'s fallback. Ignore $request->title when $collection->autoGeneratesTitles() — the field is hidden, there's no user input to preserve.
The slug half needs to keep working for people who type their own, and the signal for that already exists — Slugify tracks it in shouldSlugify:
shouldSlugify: this.enabled && !this.to,
...
to(to) { if (to !== this.slug) this.shouldSlugify = false; }When the auto-slugifier writes the value, to matches its own internal slug and the flag stays true. As soon as a human edits the input, it flips to false for good. That's exactly "the user owns this slug", and it already initialises correctly on existing entries.
So: surface that flag up to the publish form (it's buried in nested component state at the moment — emit it or stash it in the field's meta), send it with the other _-prefixed save meta, and in resolveSlug() ignore $request->slug when we're still in auto mode, honouring it verbatim when we're not.
I'd rather do it this way than flush the pending requests in the save pipeline. Both fix the race, but this one doesn't depend on timing, so it won't quietly regress the next time something async gets added to that chain.
Worth knowing this isn't strictly a new bug — the same if ($request->slug) return $request->slug; can already persist a stale slug on a normal collection if you save fast enough. It's just much narrower there. The auto/manual flag would fix the general case too.
Smaller things
EntriesController::edit()setsactions.titleFormatregardless ofreadOnly, but the controller authorisesupdate. A view-only user whose values shift on mount gets a 403 into.catch(e => { if (e.code !== 'ERR_CANCELED') throw e }), i.e. an unhandled rejection and no feedback. Worth gating the URL oncan('update', $entry).EntryTitleFormatController::create()doesn't null-check the blueprint, so an unknown handle 500s on$blueprint->fields().EntriesController::create()throws a friendly exception for this, and also callsensureCollectionIsAvailableOnSite(), which this skips.- The watcher is on the whole
valuesobject, so it fires for every field, not just the ones the format uses. Typing a long Bard body on a{first_name} {last_name}collection sends a request every 300ms to get back an unchanged title. Live Preview does the same thing but only while its panel is open. Since the server knows the format, it could ship the referenced handles alongsideactions.titleFormatand the client could skip most of these. create()pulls the date unconditionally whileedit()guards with$values->has('date'). Harmless either way, but pick one.edit()ignores theblueprintthe JS sends and uses$entry->blueprint()instead, so switching blueprint in the form previews against the old one. Either honour it or stop sending it.
Tests
The PHP coverage on the happy paths is good. Two gaps I'd like closed:
- Nothing asserts the property this whole thing hinges on. An
Event::fake()test asserting no entry lifecycle events fire and that no entry was created would lock in "this endpoint never persists anything" against future refactors. - No permission test on the edit route (a view-only user should get a 403), and no coverage of the edit path with revisions enabled.
Some vitest coverage for the debounce, the title/slug exclusion and the serialised-equality loop guard would be good too, since that guard is the only thing stopping the response from retriggering its own watcher.
the browser generates the slug asynchronously, so what it submits can lag
behind the values it came from. the slug fieldtype now surfaces slugify's
`shouldSlugify` flag through the field's meta, the publish form sends it as
`_auto_slug`, and `resolveSlug()` derives the slug itself while it's set.
`$request->title` is also ignored on collections that auto generate titles,
since `Entry::save()` overwrites it anyway.
also from review:
- gate the title format endpoint on `can('update')`
- null check the blueprint in `EntryTitleFormatController::create()`
- honour the blueprint the form sends when editing
- only pull the date when it was submitted
- ship the fields the format references so the form can skip requests for
everything else
This pull request fixes an issue where entry titles generated using a given
title_formatwouldn't receive a slug until they were saved.This was happening because the title is only generated during
Entry::save()and theslugis generated from thetitlefield, but with a title format that field ishiddenand sits empty in the publish form so there was nothing to slugify.This PR fixes it by generating the title in the publish form as values change. IMO it's a much better user experience all around to see the slug update straight away.
Before
Creating an entry
CleanShot.2026-08-12.at.12.38.39.mp4
Updating an entry
CleanShot.2026-08-12.at.12.39.07.mp4
After
Creating an entry
CleanShot.2026-08-12.at.12.34.44.mp4
Updating an entry
CleanShot.2026-08-12.at.12.35.38.mp4
Fixes #7373