wexample / symfony-tunnels
A tunnels service for Symfony
Requires
- php: >=8.5
- doctrine/orm: ^3.0
- symfony/form: ^7.4
- wexample/php-pseudocode: >=1.0.0
- wexample/symfony-forms: >=8.0.0
- wexample/symfony-helpers: >=10.0.0
- wexample/symfony-pseudocode: >=3.0.0
- wexample/symfony-translations: >=4.0.0
Requires (Dev)
- phpunit/phpunit: *
- symfony/browser-kit: ^7.4
- symfony/css-selector: ^7.4
- wexample/symfony-testing: >=2.0.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
- dev-main
- 5.0.0
- 2.0.3
- 2.0.2
- 2.0.1
- 2.0.0
- 1.0.93
- 1.0.92
- 1.0.91
- 1.0.90
- 1.0.89
- 1.0.88
- 1.0.87
- 1.0.86
- 1.0.85
- 1.0.84
- 1.0.83
- 1.0.81
- 1.0.80
- 1.0.79
- 1.0.78
- 1.0.77
- 1.0.76
- 1.0.75
- 1.0.74
- 1.0.73
- 1.0.72
- 1.0.71
- 1.0.70
- 1.0.69
- 1.0.68
- 1.0.67
- 1.0.66
- 1.0.65
- 1.0.64
- 1.0.63
- 1.0.62
- 1.0.61
- 1.0.60
- 1.0.59
- 1.0.58
- 1.0.57
- 1.0.55
- 1.0.54
- 1.0.50
- 1.0.49
- 1.0.48
- 1.0.47
- 1.0.46
- 1.0.45
- 1.0.44
- 1.0.43
- 1.0.42
- 1.0.41
- 1.0.40
- 1.0.39
- 1.0.38
- 1.0.37
- 1.0.36
- 1.0.35
- 1.0.34
- 1.0.33
- 1.0.32
- 1.0.31
- 1.0.30
- 1.0.29
- 1.0.28
- 1.0.27
- 1.0.26
- 1.0.25
- 1.0.23
- 1.0.22
- 1.0.21
- 1.0.20
- 1.0.19
- 1.0.18
- 1.0.17
- 1.0.16
- 1.0.15
- 1.0.10
- 1.0.9
- 1.0.8
- 1.0.7
- 1.0.6
- 1.0.5
- 1.0.4
- 1.0.3
- 1.0.2
- 1.0.1
- 1.0.0
- 0.1.47
- 0.1.46
- 0.1.44
- 0.1.43
- 0.1.42
- 0.1.41
- 0.1.40
- 0.1.39
- 0.1.38
- 0.1.37
- 0.1.36
- 0.1.35
- 0.1.34
- 0.1.33
- 0.1.32
- 0.1.31
- 0.1.30
- 0.1.29
- 0.1.27
- 0.1.26
- 0.1.25
- 0.1.24
- 0.1.23
- 0.1.22
- 0.1.20
- 0.1.19
- 0.1.18
- 0.1.17
- 0.1.16
- 0.1.15
- 0.1.14
- 0.1.13
- 0.1.12
- 0.1.11
- 0.1.10
- 0.1.9
- 0.1.8
- 0.1.7
- 0.1.6
- 0.1.5
- 0.1.3
- 0.1.2
- 0.1.1
- 0.1.0
This package is auto-updated.
Last update: 2026-09-24 16:55:32 UTC
README
Version: 5.0.0
symfony-tunnels is a Symfony bundle for multi-step flows — "tunnels" — that branch. A tunnel is declared as a tree of steps: each step lists what may follow it, the same step can appear on several branches with different options, and every position in that tree is a cursor with a stable hash. The engine keeps the visitor's progress in the database (TunnelSession, TunnelSessionVariable), decides from the URL, the query options and the path already walked which cursor a request is about, sends the visitor back when an earlier step is unfinished, and drops what an abandoned branch had stored when they go back and take another one. Controllers only declare their entry point with #[TunnelRoute]; rendering goes through symfony-loader, and the navigation feeds the design system stepper. It targets checkout, onboarding or questionnaire flows whose next step depends on earlier answers.
Table of Contents
- Architecture
- Integration in the Suite
- Dependencies
- Versioning & Compatibility Policy
- License
- About us
- Migration Notes
Architecture
A tunnel is a tree built fresh at every request from service declarations, walked by a visitor whose progress lives in the database. The sections below follow a request through the layers, from the bundle wiring to the rendered step.
Bundle and dependency injection
src/WexampleSymfonyTunnelsBundle.php implements LoaderBundleInterface, so its assets/ directory — partials and translations — is reachable as @WexampleSymfonyTunnelsBundle/…, and PseudocodeBundleInterface, so its entities are exported to TypeScript. On build() it registers src/DependencyInjection/Compiler/TunnelControllersCompilerPass.php.
src/DependencyInjection/WexampleSymfonyTunnelsExtension.php loads src/Resources/config/services.yaml and registers two autoconfiguration rules, because tunnels and their controllers live in applications, outside this bundle's own services: every AbstractTunnelManagerService gets the tag wexample.symfony_tunnels.tunnel, every AbstractTunnelController the tag wexample.symfony_tunnels.controller.
src/Service/TunnelRegistry.php receives the tagged managers and indexes them by name. The compiler pass turns the tagged controllers into a list of class names, the wexample_symfony_tunnels.controllers parameter, so that loading routes reads attributes without building controllers.
Tree: cursors and steps
src/Service/Step/AbstractTunnelStep.php is the base of every step. A step is a shared service and holds no state: every hook receives the cursor it acts on. getAllowedNextSteps() lists what may follow, either a step or ['step' => …, 'options' => […], 'name' => …]; every ancestor may rewrite that list through alterNextStepAllowedFollowings().
src/Service/AbstractTunnelManagerService.php names a tunnel and its entrypoint step. createEntrypoint() asks the entrypoint step for its cursor, which recursively creates the whole tree. A step reached by two paths gives two cursors: the structure is a tree, not a graph. The manager registers each cursor by hash and refuses one whose step and options already appear above it, throwing src/Exception/TunnelCycleException.php.
src/Class/TunnelCursor.php is one node: a step, its options, its parent and children, and a hash built from the manager class, the step class, the parent hash and the options. The hash is stable across requests, which is what makes it the key of everything stored for the cursor. The cursor also carries the tree walks (forEachPreviousRecursive, forEachCursorBetween…), the option matching (hasOptions for "at least these", hasSameOptions for "exactly these"), and the completion flag, a cursor variable named tunnel-step-complete.
The manager is the one stateful object of a request: it holds the cursors, the current cursor and the session. It is not shared between tunnels.
Session and variables
src/Entity/TunnelSession.php is one walk through one tunnel: its name, a status from src/Enum/TunnelSessionStatus.php, a 32-character random resume hash, the last cursor displayed, the visitor's security identifier when there is one, and the variables. src/Entity/TunnelSessionVariable.php holds one JSON value, scoped to a cursor hash or, when that hash is null, to the whole session; the two scopes never overlap. initial marks the values the tunnel was opened with.
The engine reaches that storage through src/Interface/TunnelSessionStorageInterface.php, implemented by src/Service/TunnelSessionService.php. The indirection is what lets the unit tests run the engine on an in-memory storage.
TunnelSessionService::findOrCreateSession() looks for the session a request belongs to: the one its ?tunnel= hash names, then the one the browser session remembers, then a new one. A found session is reused only for the same tunnel and the same user identifier, and only while it is not expired — opened sessions expire after one day, completed and pending ones never do. initCursorSession() starts over when the step asks for it (tunnelSessionRecreate(), true for a completed session by default), and purgeExpiredSessions() drops expired sessions after letting every step clean up in onSessionDestroy().
The values a tunnel is opened with are declared in getInitVariablesConfig() (type, required, default, autoInit), checked by setInitialVariables(), and stored as initial variables — entities as their identifier. autoInitVariables() rebuilds them from a session without a request.
Routing
src/Attribute/TunnelRoute.php marks a controller action as a tunnel entry point. src/Routing/TunnelRouteLoader.php, a tunnel_routes loader built on symfony-helpers' AbstractRouteLoader, turns each one into a route named tunnel_<tunnel>_<name> on /tunnel/<controller>/<name>/<prefix>/{step?}/<suffix>, the name being left out for index. The application imports it with src/Resources/config/routes.yaml.
src/Service/TunnelRoutingService.php builds the URL of a cursor: the tunnel route, the route params of the manager and the step (step=<name> by default), and the options under cursor-options when the cursor has some.
Request
src/Controller/AbstractTunnelController.php extends symfony-loader's AbstractPagesController. handleTunnelRequest():
- Finds the manager in the registry, builds the tree, purges expired sessions and applies the opening values — a mismatch is a 404.
- Finds or creates the session and hands it to the manager.
- Guesses the cursor: every cursor whose route params the request carries is a candidate, and
AbstractTunnelManagerService::selectCurrentCursor()narrows them down with the step's own veto, the query options, the path from the last cursor displayed and the branch it had recorded (redirects-to), then keeps the one closest to the root. - Without a cursor, redirects to the entrypoint when no step was named, and answers 404 when one was.
- Asks the step whether it
needsRedirect(). ARedirectResponseis returned as is; a cursor becomes a redirect to its URL, after completing the step if its strategy completes on redirect. Every redirect keeps__layout, so a tunnel opened in a modal stays in it. - Otherwise calls
initAsCurrentStep()and renderstunnels/<tunnel>/<step>from the controller's front directory, withtunnel,tunnelStepandtunnelCursor.
initAsCurrentStep() is where the rules of the path apply: it records the last cursor displayed, notifies the ancestors, completes a parent waiting for its child (ON_NEXT_INIT), and when the step had already sent the visitor down a branch, calls onPreviousStepLoading() on it — which by default drops every cursor variable of that branch. Then it applies the step's own strategy from src/Enum/TunnelStepCompleteStrategy.php.
Form steps
src/Service/Step/AbstractFormTunnelStep.php is a step showing a form, completed only when the form is (MANUAL strategy). It names an ordinary symfony-forms processor through getFormProcessor(), which does what a valid submission means for the application, and gives the form its starting data through buildFormData(). The step itself only says where the tunnel goes next, in onFormValid(): by default it marks itself complete and returns the next cursor, while returning null keeps the visitor on the step.
AbstractTunnelController::handleFormStep() shows the form on GET. On POST it lets the processor handle the submission, asks the processor whether the form is valid — the same check that decides whether its own onValid() runs — and turns the step's answer into the processor's success action: embed_redirect to the next cursor when the request comes from a modal, panel or overlay, redirect otherwise, embed_stay when the step returned null. A JSON request gets the payload of symfony-forms' FormResponsePayloadBuilder; a page request gets a redirect, or the form again with its errors. The form posts back to the step URL, so its class must not set $ajax = true.
Asynchronous resume
src/Service/TunnelResumeService.php takes up a flow left waiting on an outside event — a payment confirmed by a webhook — with no visitor and no URL. The waiting step sets its session to pending_async_action and keeps the identifier the event will carry as a variable. The application's event handler calls resumeByVariable($name, $value, $onCursor), which finds every pending session holding that value, rebuilds its tunnel, restores its opening values through autoInitVariables(), and hands the cursor the variable belongs to to the callback.
Access rules
AbstractTunnelStep::needsRedirect() sends the visitor to the direct parent when it is incomplete, unless that parent completes on its child, then to the first incomplete ancestor under the default strategy of src/Enum/TunnelStepRedirectStrategy.php.
allowDirectAccess() decides whether a link is offered: any cursor between the two ends can veto it through allowAccessOf(), and a link forward requires every ancestor of the target to be complete, so it never leads to a redirect back.
Navigation and tooling
src/Helper/TunnelTreeHelper.php reads a built tree: its root-to-leaf paths, its map of sections (one row per step and options, in an order every path agrees with), and the known steps around a cursor — the step groups every remaining path goes through, with a null where paths diverge. src/Class/TunnelNavigationItem.php and src/Class/TunnelTreeSection.php carry the results.
src/Twig/TunnelExtension.php exposes tunnel_cursor_url, tunnel_stepper — the options of the design system stepper, a divergence becoming an unknown step — and tunnel_previous_url / tunnel_next_url. The partials assets/partials/tunnel-navigation.html.twig and assets/partials/tunnel-buttons.html.twig use them.
src/Service/TunnelTracingService.php draws the tree and the map as text for src/Command/InfoCommand.php, tunnels:info <name>.
Tests
tests/Fixtures/App is a kernel on in-memory SQLite with the loader, translations, forms and tunnels bundles. tests/Fixtures/Tunnel holds the test tunnel ported from the legacy code, the executable specification of the engine: step two appears three times with different options, step three bis three times as named variants, and the steps exercise every completion strategy, internal and external redirects, a direct-access veto and a branch reset. A second, two-step tunnel in tests/Fixtures/Tunnel/FormTunnel covers form steps. tests/Unit runs the engine on the in-memory storage; tests/Integration runs it on the database and over HTTP.
Integration in the Suite
This package is part of the Wexample Suite — a collection of high-quality, modular tools designed to work seamlessly together across multiple languages and environments.
Related Packages
The suite includes packages for configuration management, file handling, prompts, and more. Each package can be used independently or as part of the integrated suite.
Visit the Wexample Suite documentation for the complete package ecosystem.
Dependencies
- php: >=8.5
- doctrine/orm: ^3.0
- wexample/symfony-helpers: >=10.0.0
- wexample/symfony-forms: >=8.0.0
- wexample/symfony-translations: >=4.0.0
- wexample/php-pseudocode: >=1.0.0
- wexample/symfony-pseudocode: >=3.0.0
- symfony/form: ^7.4
Versioning & Compatibility Policy
Wexample packages follow Semantic Versioning (SemVer):
- MAJOR: Breaking changes
- MINOR: New features, backward compatible
- PATCH: Bug fixes, backward compatible
We maintain backward compatibility within major versions and provide clear migration guides for breaking changes.
License
This project is licensed under the MIT License - see the LICENSE file for details.
Free to use in both personal and commercial projects.
About us
Wexample stands as a cornerstone of the digital ecosystem — a collective of seasoned engineers, researchers, and creators driven by a relentless pursuit of technological excellence. More than a media platform, it has grown into a vibrant community where innovation meets craftsmanship, and where every line of code reflects a commitment to clarity, durability, and shared intelligence.
This packages suite embodies this spirit. Trusted by professionals and enthusiasts alike, it delivers a consistent, high-quality foundation for modern development — open, elegant, and battle-tested. Its reputation is built on years of collaboration, refinement, and rigorous attention to detail, making it a natural choice for those who demand both robustness and beauty in their tools.
Wexample cultivates a culture of mastery. Each package, each contribution carries the mark of a community that values precision, ethics, and innovation — a community proud to shape the future of digital craftsmanship.
Migration Notes
When upgrading between major versions, refer to the migration guides in the documentation.
Breaking changes are clearly documented with upgrade paths and examples.