leapt/slug-type-bundle
Symfony bundle that adds a reusable SlugType form field for generating URL-friendly slugs from user input. It streamlines slug creation and validation in forms, helping keep routes and content identifiers clean, readable, and consistent across your application with minimal setup.
This is a Symfony bundle, not a Laravel package — there is no service provider, config publish tag, or Slug facade. If you're coming from Laravel, treat it as a reference implementation you can either (a) use inside a Symfony app, or (b) copy the ~100-line JS + form-type pattern into a Laravel + Alpine/Stimulus setup.
Minimal steps in a Symfony app:
composer require leapt/slug-type-bundle:^1.5 (Symfony Flex will usually register it in config/bundles.php; otherwise add Leapt\SlugTypeBundle\LeaptSlugTypeBundle::class => ['all' => true]).php bin/console assets:install public (or import the shipped JS into your Encore/AssetMapper pipeline if you build assets).use Leapt\SlugTypeBundle\Form\Type\SlugType;
$builder
->add('title', TextType::class)
->add('slug', SlugType::class, [
'target' => 'title', // property name of the source field
]);
Version note (v1.5.0): the only functional change is a widened symfony/stimulus-bundle constraint (now allows v3) plus CI/workflow housekeeping. No BC breaks, no new form options, no config changes — upgrade freely. The practical effect: if your app is on Symfony UX / stimulus-bundle 3.x, v1.4.x would have blocked composer update; v1.5.0 unblocks it. The mention of Stimulus is a strong hint the shipped JS is (or can be) registered as a Stimulus controller — check src/Resources/assets/ before writing your own re-init glue.
First real use case: an admin/article form where editors type a title and the slug populates live underneath it, but they can still override it before saving. Look first at src/Form/Type/SlugType.php and the shipped JS file — the whole surface area is tiny, so reading the source is faster and more reliable than the README.
Always pair the widget with a server-side slug fallback. The auto-fill is pure JS (it reads the target input and writes a slugified value into the slug input on keyup/input). Anything that can bypass the DOM — API clients, curl, tests, disabled JS — will submit an empty slug. Do the real work in your entity setter or a PRE_SUBMIT form listener:
use Symfony\Component\String\Slugger\AsciiSlugger;
public function setTitle(string $title): self
{
$this->title = $title;
if (!$this->slug) {
$this->slug = strtolower((new AsciiSlugger())->slug($title));
}
return $this;
}
Only fill when the slug is untouched. Good UX pattern is: keep the generated value until the user manually edits the slug field, then stop overwriting. The bundle's JS typically respects this, but verify — otherwise every keystroke in the title clobbers a hand-curated slug.
Reuse via a base form type. If you have many entities with slug fields, create an abstract SluggableFormType that adds title/slug and let concrete forms extend it. This keeps the target and type options in one place.
EasyAdmin / Sonata integration. Both can render arbitrary Symfony form types, so you get the widget "for free" by naming SlugType::class in the field config — but check that the admin's form theme doesn't strip the data-* attributes the JS depends on (see Gotchas).
Asset pipeline choice. With Webpack Encore, import the bundle's JS from your app.js. With AssetMapper, importmap or a simple asset() reference to the installed public path. If the bundle exposes a Stimulus controller (v1.5.0's constraint bump points that way), prefer {{ stimulus_controller(...) }} / bootstrap.js registration over hand-rolled listeners — you inherit the connect/disconnect lifecycle for free. Pick one approach and document it — mixing causes "works on my machine" bugs.
Turbo / Stimulus / Live Components. If you use Hotwired Turbo (or Symfony UX Live Components), a page transition re-renders the form without a full reload, and ad-hoc DOM listeners won't be re-attached. Two options: register the bundle's controller via stimulus-bundle (auto-connects on Turbo navigation — the low-effort path on v1.5.0), or re-initialize manually on turbo:load / live_component:render.
Porting the pattern to Laravel. The logic is trivial to replicate: an Alpine x-on:input on the title that calls a Str::slug()-ish JS function and writes into the slug input, gated by a slugDirty flag. Don't try to composer require this into a Laravel app — it will pull Symfony Form/OptionsResolver and nothing will wire up.
stimulus-bundle below v3. If you have a locked composer.lock from an older app, run composer update leapt/slug-type-bundle symfony/stimulus-bundle together — updating only one can leave you in a constraint deadlock.target is the field name, not the label. If you use nested forms or property_path, the target must match the child field name the JS queries by id/name. Rename a field and the JS silently targets nothing.data-* attributes emitted by the bundle's widget. If you override form_widget for text types — including via EasyAdmin/Sonata field templates — you can lose them and break the JS.AsciiSlugger/cocur/slugify handle accents, CJK, and separators differently. Users will occasionally see one value in the input and a different one after save. Normalize in one place (server) and treat the JS as cosmetic.turbo:load hook. If you register the shipped Stimulus controller and keep a legacy listener, the slug field can be updated twice or fight the dirty flag. Pick one mechanism.target, and whatever the type exposes). That's a feature: nothing to misconfigure.SlugType to bake in defaults. All three are low-risk because the public API is a single form type and a couple of options.How can I help you explore Laravel packages today?