Not every form needs autosave. Sometimes the most useful thing an application can do is ask a simple question before someone navigates away:
You have unsaved changes. Leave this page?
It's a small detail, but a helpful one on editing screens: account settings, product details, admin forms, or anything that may take a few minutes to fill out.
I wanted a reusable way to add that behavior to a Symfony form without creating a new form type, adding server-side infrastructure, or writing another one-off JavaScript controller for each project.
That became Unsaved Form Bundle, a small open-source Symfony UX bundle built around one Stimulus controller. Version 1.0.0 is available on Packagist.
This post is about the convenience itself, how to use it, and some less obvious browser and Turbo behaviors worth understanding before relying on any unsaved-changes warning.
Why beforeunload alone is not quite enough
For a traditional page, the starting point is familiar:
window.addEventListener('beforeunload', (event) => {
if (!hasUnsavedChanges) {
return;
}
event.preventDefault();
event.returnValue = '';
});
That illustrates the native browser mechanism, but it isn't a complete implementation. We still need to determine whether a form really changed, stop warning after a successful save, and avoid warnings when an ordinary submission is already in progress.
And once Turbo is involved, some navigations replace content without unloading the document at all.
For example:
- Turbo Drive can visit a different page without a full browser reload.
-
Turbo Back/Forward restoration doesn't fire
turbo:before-visit. - Turbo Frames can replace only the part of the page containing the form.
- Turbo Streams can replace or remove the form without changing the URL.
Those are separate events in Turbo's documented lifecycle. A guard that listens only to beforeunload can miss them.
The goal wasn't to build a form framework. It was to make this small piece of behavior reusable.
Add it to a Symfony form
The package requires PHP 8.2+, Symfony 7.4 or 8.x, and a supported Symfony StimulusBundle integration. It works in applications with or without Symfony UX Turbo.
Install the stable 1.x release:
composer require "alkin/unsaved-form-bundle:^1.0"
In a standard Symfony UX setup, Symfony Flex registers the bundle and the Stimulus controller. It's worth checking config/bundles.php, assets/controllers.json, and the application's frontend configuration after installation.
Then add a controller to the form element:
{{ form_start(form, {
attr: {
'data-controller': 'alkin--unsaved-form-bundle--guard',
'data-alkin--unsaved-form-bundle--guard-message-value':
'You have unsaved changes. Leave this page?'
}
}) }}
{{ form_widget(form) }}
<span role="status" aria-live="polite" hidden
data-alkin--unsaved-form-bundle--guard-target="indicator">
Unsaved changes
</span>
<button type="submit">Save</button>
{{ form_end(form) }}
That's the basic integration. The message and indicator are optional; the form does not need an extra controller action or a database table.
The optional indicator becomes visible while the form has unsaved changes. As with other dynamic UI messages, check that its accessibility behavior fits your own form and assistive-technology setup.
It is not limited to Symfony Forms
The controller attaches to a regular HTML <form> element too:
<form data-controller="alkin--unsaved-form-bundle--guard" method="post">
<label>
Title
<input name="title" value="Example">
</label>
<button type="submit">Save</button>
</form>
You still need StimulusBundle and the frontend assets to be loaded. Turbo is optional.
A change is not the same as a touched field
An important design choice is to compare a form's current values with its starting values rather than remember whether an input event happened at some point.
Consider this simple sequence:
- A field starts with
Draft. - Someone edits it to
Published. - They change it back to
Draft.
The form should be considered clean again.
The controller takes a snapshot of relevant form values and compares against that baseline. It accounts for ordinary text fields, checkboxes, radio buttons, selects, textareas, and selected files. It also reacts to resets and detects changes made programmatically when it checks the form before navigation.
Sometimes a value should not affect the warning, such as a purely client-side helper field. It can be excluded:
<input name="temporary-ui-state" data-unsaved-form-ignore>
The attribute can also be placed on a wrapper around fields that should be ignored.
What happens with Turbo?
For normal Turbo Drive visits, the controller can ask for confirmation before the visit begins. For native browser navigation, it relies on the browser's beforeunload dialog.
History restoration needs different handling. Turbo explicitly does not emit turbo:before-visit for Back/Forward navigation, so that event alone cannot protect an edited form. The bundle also uses browser history information where available and keeps the original form state in page memory so a cached Turbo render does not simply turn an unsaved form into a new, apparently clean one.
Frames and Streams are another case: they can remove or replace an edited form without leaving the page. The bundle listens to their render lifecycle and asks for confirmation when the pending update would affect that form. Unrelated frame or stream updates can continue.
There's one more detail that's easy to overlook: validation failures.
A Symfony form submitted through Turbo may return an HTTP 422 response containing validation errors and re-rendered form markup. That is not a successful save. The new form should still be treated as containing unsaved edits, rather than resetting its baseline just because new HTML appeared.
These behaviors are why the controller grew beyond a short beforeunload snippet. They are also why I would not judge a navigation guard only by a couple of happy-path unit tests.
Submission and custom JavaScript
A regular form submission should not trigger an unnecessary leave-page warning simply because the browser is submitting the form.
For Turbo submissions, the controller follows Turbo's submission lifecycle. A successful response can mark the current state clean; an unsuccessful one should keep the unsaved state. For an application that saves via custom AJAX code outside that lifecycle, the controller exposes a small method, markClean(), which the application can call after the server has confirmed a successful save.
This is deliberate: the bundle does not guess that a click on a Save button means data was persisted.
It also emits an alkin--unsaved-form-bundle--guard:change event with event.detail.dirty if you want to update other UI elements when the dirty state changes.
What this does not promise
It's important to keep the scope clear.
It is a navigation warning, not a draft storage system. It does not store edits in a database or localStorage, automatically save to the server, or guarantee recovery after a tab or browser process disappears. The temporary state used during Turbo restoration lives in the page's JavaScript memory.
Browsers also control native unload dialogs. They may require prior user interaction, they choose the dialog text, and on some mobile or OS-initiated termination paths they may not show a prompt at all. This is a browser constraint, not something a Symfony bundle can override. MDN's beforeunload documentation explains the limitations.
Similarly, the history behavior depends on modern browser APIs being available. And if unrelated application code deliberately destroys a form outside the supported navigation and Turbo lifecycle, that code needs to coordinate with the guard.
Those boundaries matter more than a claim that it can "never lose changes." If an application needs guaranteed draft recovery, it should implement actual persistence or autosave separately.
Testing beyond a single browser
For the v1.0.0 release, the project includes 29 JavaScript unit tests and real-browser navigation scenarios using Playwright and Turbo 8. Its CI checks Chromium, Firefox, and WebKit, alongside PHP 8.2/8.4 checks.
The browser scenarios exercise ordinary and Turbo navigation, rejected and approved Back/Forward visits, Frames, destructive Streams, form submission, and an HTTP 422 validation response. The file-input scenario checks that declining a history navigation doesn't discard a selected file in the tested browser behavior.
That doesn't prove every browser version or integration is perfect. It does make regressions in those specific paths easier to detect.
If you're evaluating this for your own project, I would still test it against the way your application uses Turbo, especially if you have custom morphing, unusual form markup, or JavaScript that replaces parts of the DOM.
A small convenience, not another form framework
Symfony already gives us excellent form, validation, and frontend tools. I didn't want another layer around them just to display one warning.
Unsaved Form Bundle is meant to stay narrow: one opt-in Stimulus controller for a behavior that can be useful in many editing screens. It won't suit every application, and it doesn't need to.
The source, usage notes, limitations, and tests are available on GitHub. The package is on Packagist.
If you try it and run into a reproducible edge case, a small example and the browser/Turbo versions would be especially helpful. That's often more useful than a long feature wishlist.
Cover photo: Jakub Zerdzicki on Pexels, used under the Pexels License.
Top comments (0)