|
|
Standalone Ibexa DXP bundle for rendering frontend-facing forms managed as Ibexa content. Forms are modelled as a content type tree — each field (input, textarea, select, choice, fieldset, horizontal group, button) is a separate content item nested under a form content item. Submissions are stored in a dedicated database table and/or delivered via email.
- PHP >= 8.3
- Ibexa DXP >= v4.4 or >= v5.0
- Symfony 5.4.x or 7.x
- Doctrine ORM ^2.11 or ^3.0
- Forms are managed as content inside the Ibexa content tree
- Uses Symfony Forms under the hood, including Validation
- Supports horizontal grouping (eg: first name, last name)
- Tag-aware result caching via Symfony Cache (cache invalidated on content publish)
- Form submissions can be stored in a dedicated
form_submissiondatabase table - Form submissions can trigger an Email notifications (configurable To/CC/BCC/Subject)
- Admin UI for browsing and viewing submissions
- Console commands to install required content types
- Symfony Flex recipe for zero-configuration installation
If your project uses Symfony Flex (recommended), add the private recipe endpoint to your project's composer.json once:
"extra": {
"symfony": {
"endpoint": [
"https://raw.githubusercontent.com/vardumper/IbexaFormBuilderBundle/main/flex/",
"flex://defaults"
]
}
}Then install:
composer require vardumper/ibexa-form-builder-bundleSymfony Flex will automatically:
- Register the bundle in
config/bundles.php - Copy the Doctrine ORM mapping config to
config/packages/ibexa_form_builder.yaml - Copy the Ibexa admin-ui config to
config/packages/ibexa_form_builder_admin_ui.yaml - Copy the route import to
config/routes/ibexa_form_builder.yaml - Copy the database migration to
migrations/
The Flex recipe copies a migration to your migrations/ directory that creates the form_submission table.
Run it:
bin/console doctrine:migrations:migratebin/console ibexa:form-builder:install-content-typesManual installation (without Symfony Flex)
return [
// ...
vardumper\IbexaFormBuilderBundle\IbexaFormBuilderBundle::class => ['all' => true],
];# config/packages/ibexa_form_builder.yaml
doctrine:
orm:
mappings:
IbexaFormBuilder:
is_bundle: false
type: attribute
dir: '%kernel.project_dir%/vendor/vardumper/ibexa-form-builder-bundle/src/Entity'
prefix: 'vardumper\IbexaFormBuilderBundle\Entity'
alias: IbexaFormBuilder# config/routes/ibexa_form_builder.yaml
ibexa_form_builder_routes:
resource: '@IbexaFormBuilderBundle/config/routes.yaml'Create the form_submission table manually or copy the migration from the bundle and run it:
CREATE TABLE form_submission (
id INT AUTO_INCREMENT NOT NULL,
content_id INT NOT NULL,
submitted_at DATETIME NOT NULL COMMENT '(DC2Type:datetime_immutable)',
data JSON NOT NULL,
ip_address VARCHAR(45) DEFAULT NULL,
PRIMARY KEY (id)
) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB;bin/console doctrine:migrations:migratebin/console ibexa:form-builder:install-content-types# config/packages/ibexa_form_builder.yaml
ibexa_form_builder:
from_email: 'no-reply@example.com' # sender address for notification emails| Command | Description |
|---|---|
ibexa:form-builder:install-content-types |
Creates or updates the content types required by the bundle (form, input, textarea, select, option, fieldset, horizontal_group, button, choice) |
ibexa:form-builder:sync-order |
Retroactively syncs the form_builder_order field value → location priority so the admin sub-item list reflects the intended field order |
Use any of the three identifiers to render a form from a controller or template:
// by content ID
$this->forward('vardumper\IbexaFormBuilderBundle\Controller\FormController::renderForm', [
'contentId' => 123,
]);You can render a form by passing its content ID, location ID or Form name:
{{ render(controller('vardumper\\IbexaFormBuilderBundle\\Controller\\FormController::renderForm', { contentId: 54 })) }}
{{ render(controller('vardumper\\IbexaFormBuilderBundle\\Controller\\FormController::renderForm', { locationId: 56 })) }}
{{ render(controller('vardumper\\IbexaFormBuilderBundle\\Controller\\FormController::renderForm', { formName: 'Search Form' })) }}Or link directly via the registered route:
/form/{identifier}
Set the submission_action field on your form content item to one of:
| Value | Behaviour |
|---|---|
store |
Saves the submission to the form_submission database table |
email |
Sends a notification email (requires notification_email field to be filled) |
both |
Stores the submission and sends the email |
Email fields on the form content item:
| Field | Description |
|---|---|
notification_email |
To address |
notification_email_cc |
CC address (optional) |
notification_email_bcc |
BCC address (optional) |
email_subject |
Email subject line (optional) |
The bundle dispatches six events throughout the form submission lifecycle, giving you fine-grained control without needing to override any services. All event names are defined as constants on FormBuilderEvents.
| Constant | Dispatched | Cancellable |
|---|---|---|
PRE_VALIDATION |
After handleRequest(), before isValid() is evaluated |
✔ Cancelling prevents SubmissionHandler from running at all |
PRE_SUBMIT |
After POST data is cleaned, before any storage or email action | ✔ Cancelling skips both; listeners may also call setData() to enrich or sanitize the data |
PRE_STORE_SUBMISSION |
Before persist() + flush() |
✔ Cancelling skips the DB write; the email action still proceeds |
POST_STORE_SUBMISSION |
After flush(); entity carries its auto-generated ID |
✗ |
PRE_SEND_EMAIL |
After the Email object is built, before sending |
✔ Cancelling suppresses the send; mutate the Email object directly to change recipients, subject, or body |
POST_SUBMIT |
End of handle(), regardless of cancellations |
✗ Always fires; getSubmission() returns null when the store step was skipped |
Cancellable events expose cancel() and isCancelled(). Calling cancel() does not call stopPropagation(), so subsequent listeners on the same event still receive it.
<?php
declare(strict_types=1);
namespace App\EventListener;
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
use vardumper\IbexaFormBuilderBundle\Event\{FormBuilderEvents, PreSubmitEvent};
#[AsEventListener(event: FormBuilderEvents::PRE_SUBMIT)]
final class SpamFilterListener
{
public function __invoke(PreSubmitEvent $event): void
{
$data = $event->getData();
if (isset($data['website']) && $data['website'] !== '') {
$event->cancel(); // honeypot field was filled — silently discard
return;
}
// Strip HTML from all string values before storage
$event->setData(array_map(
static fn (mixed $v) => is_string($v) ? strip_tags($v) : $v,
$data,
));
}
}<?php
declare(strict_types=1);
namespace App\EventListener;
use GuzzleHttp\ClientInterface;
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
use vardumper\IbexaFormBuilderBundle\Event\{FormBuilderEvents, PostStoreSubmissionEvent};
#[AsEventListener(event: FormBuilderEvents::POST_STORE_SUBMISSION)]
final class CrmIntegrationListener
{
public function __construct(private readonly ClientInterface $httpClient)
{
}
public function __invoke(PostStoreSubmissionEvent $event): void
{
$submission = $event->getSubmission();
$this->httpClient->request('POST', 'https://crm.example.com/api/leads', [
'json' => [
'source_id' => $submission->getId(),
'form_id' => $submission->getContentId(),
'data' => $submission->getData(),
],
]);
}
}This bundle uses Pest for testing.
composer install
vendor/bin/pestXDEBUG_MODE=coverage vendor/bin/pest --coverage-html=coverage-report