sideclick/bootstrap-modal-bundle
Sideclick Bootstrap Modal Bundle helps Symfony applications integrate Bootstrap modals with reusable helpers for rendering dialogs and displaying dynamic content, forms, and AJAX-loaded views in a consistent, template-friendly way.
Reality check before anything else. sideclick/bootstrap-modal-bundle is a Symfony bundle, not a Laravel package. It ships a Symfony Bundle class, DI definitions, Twig extensions, and a Symfony-flavored controller/listener — none of which Laravel's container or package discovery will load. composer require may succeed, but nothing it does will be wired up, and you will not get a service provider, facade, or config file. For a Laravel developer, "leveraging this package day-to-day" means reading it once as a reference implementation and building the equivalent in your own stack. Do not add it to composer.json, even in require-dev — a dead dependency in the lockfile invites accidental use.
Where to look first. Clone the repo and read three things in this order:
That trio is the entire design. Everything else is Symfony glue you will discard.
First use case. Pick exactly one flow — the canonical one is "edit a record from a listing page." Concretely, in Laravel terms:
GET /users/{user}/edit that returns view('users._form') when the request is modal, and view('users.edit') otherwise.<x-modal-link href="{{ route('users.edit', $user) }}">Edit</x-modal-link> that renders an <a> with the right data attributes.X-Modal-Request: 1 header, and injects the HTML into a Bootstrap 5 modal body.Minimal steps to a first working spike:
<meta name="csrf-token" content="{{ csrf_token() }}"> to your layout (you'll need it for form posts inside the modal).<div class="modal fade" id="app-modal">…).$request->header('X-Modal-Request') → return the partial.click handler → fetch → set modal-body.innerHTML → bootstrap.Modal.getOrCreateInstance(el).show().Response negotiation is the core pattern. The bundle's whole trick is that one route serves two representations. In Laravel, express that explicitly rather than scattering if statements:
// app/Http/Requests/ModalRequest.php or a small trait
public function wantsModal(): bool
{
return $this->header('X-Modal-Request') === '1'
|| $this->boolean('_modal');
}
Then in the controller: return $request->wantsModal() ? view('users._form', compact('user')) : view('users.edit', compact('user'));
A middleware that injects wantsModal() via a macro, or a ResponseFactory helper, keeps this out of every controller. Avoid the ?_modal=1 query-string variant if you can — it leaks into analytics and into users' bookmarks; the header is cleaner.
The trigger component. Encapsulate the markup so you're not hand-writing data-* attributes on every link:
{{-- resources/views/components/modal-link.blade.php --}}
<a href="{{ $href }}"
data-modal-trigger
data-modal-title="{{ $title ?? '' }}"
data-modal-size="{{ $size ?? 'md' }}"
{{ $attributes->merge(['class' => 'btn btn-sm btn-outline-secondary']) }}>
{{ $slot }}
</a>
Mirror the bundle's original options — size, backdrop, keyboard — as component props. That's the one place the reference implementation gives you a directly reusable decision set.
Form workflows inside the modal. This is where generic modal loaders break down and where you'll spend most of your time. The pattern that works:
<form method="POST" action="{{ route('users.update', $user) }}"> with @csrf and @method('PUT').422:
return response()->view('users._form', compact('user'), 422); — the JS checks response.status === 422 and re-injects the body (which now carries @error messages), without closing the modal.response.redirected, or return a 204 with a X-Modal-Close: 1 header and let JS close and window.location.reload(). The header approach is more explicit and easier to test.Loading and error states. Show a spinner in modal-body on open, and a plain error panel if fetch rejects or returns >= 500. Without this, a failed fetch leaves an empty white dialog and looks like a hang.
Stacking. The bundle is thin here; you own it. Keep a module-level array of open modal element IDs. When a trigger inside a modal fires, push a new instance, and on hidden.bs.modal restore focus to the element that opened the previous one and dispose the instance. Never reuse the same DOM node for two modals — the close animation races with the reopen.
Integration tips.
document (document.addEventListener('click', e => e.target.closest('[data-modal-trigger]')?.…)). Injected HTML has no listeners of its own, so delegation is mandatory.@once in your layout, or load it from resources/js/app.js.hx-* attributes. Only hand-roll the fetch loop if neither is in the stack.Framework mismatch is the headline gotcha. Nothing in this bundle runs under Laravel. If someone on the team adds it, they'll be debugging Symfony service wiring that will never execute. Record the "pattern, not dependency" decision in an ADR so the question doesn't reopen.
Auth expiry silently injects a login page. Laravel's auth middleware returns a 302 to /login for an unauthenticated request. fetch follows redirects by default, so your modal body will display your login form inside a "Edit User" dialog. Fix: the JS should send Accept: application/json (so Laravel returns 401 instead of redirecting) and detect response.status === 401, then window.location = '/login'.
CSRF on modal form posts. The partial's form needs @csrf, which works only if csrf_token() is stable in the modal context — it is, but only because it reads the session. Where people trip up is JS-fetching forms whose token was generated before a session regeneration (e.g., after login in another tab). Sending X-CSRF-TOKEN from the meta tag on every request, and reading it fresh at fetch time, sidesteps this.
Bootstrap 5 changed everything. The bundle predates Bootstrap 5: no jQuery, data-bs-toggle/data-bs-dismiss, and a completely different Modal API (bootstrap.Modal.getOrCreateInstance(el) vs $(el).modal()). Any JS you copy out of the bundle's driver will not run. Rewrite it; don't port it.
Scripts inside injected HTML do not execute. Setting innerHTML neuters <script> tags and inline onclick handlers. If the partial needs behavior (a date picker, a select2), initialize it explicitly after injection, in a single "after inject" hook. This also means CSP script-src policies with no unsafe-inline won't break you — a good thing to preserve.
CSP and XSS. You're injecting server-rendered HTML into the DOM. Treat the trust boundary as same-origin, authenticated, server-controlled URLs only. Never take the href from user input; never pass a URL through the query string into the loader. If you do, you've built a reflected XSS with extra steps.
Accessibility is not free. Bootstrap 5 traps focus and handles Escape for top-level modals, but not stacked modals, and not focus return to a re-rendered body. Verify: aria-modal="true", aria-labelledby pointing at the title, focus returning to the trigger on close, and Escape closing the topmost dialog only. Screen-reader users get nothing if focus isn't managed manually after a validation re-render.
Deep links and the back button. A modal state is not a URL. Users will want to share …/users/5/edit and hit refresh — so that route must also render a full page when fetched without the header. That's why the response-negotiation helper is the design keystone: it makes the modal URL shareable and the non-modal fallback work simultaneously.
Double-submit and close races. Disable the submit button on submit and re-enable on error. If you don't, a slow request lets the user fire twice, and the modal that closes mid-request leaves an orphan.
Analytics blind spot. A modal open is not a page view. If you care about engagement on modal flows, emit an explicit event (window.dataLayer?.push({ event: 'modal_open', route })) from the loader. Otherwise those interactions vanish from default instrumentation.
Debugging checklist when "the modal is empty."
Content-Type and body — if it's your full layout, the wantsModal() check failed (headers stripped by a proxy? header name typo?).X-Modal-Request: 1.302 (auth/CSRF). fetch follows redirects; log response.redirected.Extension points to mirror from the bundle. The config surface (size, backdrop, keyboard, and any theme/template overrides) is the useful part to replicate as Blade component props and a small JS options object. Everything else — DI, Twig extension, Symfony event wiring — is noise in a Laravel context; skip it.
How can I help you explore Laravel packages today?