Extending
Field types
A field type is a class extending Packstub\FormBuilder\Fields\FieldType:
| Method | Purpose |
|---|---|
id() |
The short id stored with every field |
label(), icon() |
Shown in the block picker (label from types.{id} in the language file) |
isInput() |
false for layout-only types |
hasChoices(), acceptsMultiple(), choices(Field $field) |
Choice lists, list values, computed choices |
hasPlaceholder(), hasDefault() |
Which common settings the builder shows |
ruleCategory() |
Which rules the picker offers: text, number, date, choice, multiple, file, boolean or null |
editorSchema() |
Extra settings shown in the builder, as Filament components |
rules(Field $field), elementRules(Field $field) |
Validation rules (required / nullable are added for you) |
prepare(mixed $value, Field $field) |
Shape the raw input before validation (split a string into a list, decode a base64 file) |
comparableValue(mixed $value, Field $field) |
The raw value as the conditions compare it |
normalize(mixed $value, Field $field) |
The stored value |
format(mixed $value, Field $field) |
The value as text (tables, emails, CSV) |
display(mixed $value, Field $field) |
The value in the details view; return an HtmlString for HTML |
tableColumn(Field $field), tableFilter(Field $field) |
A column and a filter for the submissions table, or null for the defaults; a column you make sortable keeps its own sort |
detailEntry(Field $field) |
An infolist entry for the submission details (its state is data.<key>), or null for the text of display() |
nestedRules(Field $field, bool $required), nestedAttributes(Field $field) |
Rules and names for the parts of a value stored as an object, validated as key.part (the address type) |
exportColumns(Field $field), exportValue(mixed $value, Field $field, string $column) |
Split the value into several export columns |
definition(Field $field) |
Extra keys for the field in the JSON definition |
view() |
The Blade view of the plain renderer (receives field, inputId, error, value) |
formComponent(Field $field) |
The Filament component of the Livewire renderer |
Extend a built-in when it is close to what you need:
use Packstub\FormBuilder\Fields\Field;
use Packstub\FormBuilder\Fields\Types\NumberField;
class ScoreField extends NumberField
{
public static function id(): string
{
return 'score';
}
public function icon(): string
{
return 'heroicon-o-star';
}
public function rules(Field $field): array
{
return ['integer', 'between:1,100'];
}
}
Register it in field_types, with FormBuilderPlugin::make()->fieldTypes([ScoreField::class]), or with FormBuilder::registerFieldTypes([...]). Hide built-ins with withoutFieldTypes([TextField::class, 'url']). A type that renders a Filament component the compiled Livewire stylesheet lacks needs frontend.livewire_theme pointed at a fuller theme (see Rendering).
Choice sources
Choice fields can take their options from FormBuilder::choices($name, $source, $label) or config choice_sources; see Building forms. A class source implements Packstub\FormBuilder\Contracts\ChoiceSource (label() and choices(Field $field)).
Templates
Offer your own templates next to the built-in ones with Templates::add($directory); see Sharing and templates.
Models and tables
Point models.form / models.submission / models.webhook_delivery / models.share_link to your subclasses for extra columns, scopes or relationships, and tables.* to other table names before migrating.
Tenancy
With tenancy.enabled, forms carry the current tenant's key in tenancy.column (tenant_id): the one Filament resolves in a panel with ->tenant(), or what the tenancy.resolver callable returns. Queries see the current tenant's forms only (outside a tenant, the public routes see every form) and new forms get the key on creation; $form->tenant() is a BelongsTo to tenancy.model. With a database per tenant (Filament Tenancy) leave it off: each tenant has its own tables. To limit forms or submissions per plan, gate the resource with Filament Features in authorize().
Ownership
Every form records the user who created it (user_id, $form->owner); the Forms list has a hidden Created by column and a My forms filter. With ownership.only_own each user sees and edits only their own forms, unless they pass the gate ability named in ownership.see_all:
// config/packstub-form-builder.php
'ownership' => ['only_own' => true, 'see_all' => 'manage-all-forms'],
// AppServiceProvider::boot()
Gate::define('manage-all-forms', fn (User $user): bool => $user->is_admin);
The public routes are not affected.
Resource
FormBuilderPlugin::make()->resource(MyFormResource::class) swaps the resource (extend FormResource); withoutResource() skips it. authorize(fn () => auth()->user()->isEditor()) and the gate config key restrict who sees it; a policy on the Form model works as well.
Embedding in another package
A package that wants to offer forms as a block or a widget can:
- render with the Blade component, passing its own
returnURL and:styles="false"to inherit the host's CSS variables, or a portable array instead of a slug; - read the definition with
$form->toDefinition(), the sections with$form->sections()and the fields with$form->fieldList(); - resolve the conditions for a set of values with
$form->visibleKeys($values); - submit with
app(Submitter::class)->submit($form, $input, SubmissionContext::fromRequest($request, 'my-package')); - list forms for a picker with
FormBuilder::formModel()::query()->where('is_active', true)->pluck('name', 'slug').
Testing
FormBuilder::fake() keeps submissions validating and storing but skips the emails, panel notifications, webhooks, channel messages and sinks, and records what came in:
use Packstub\FormBuilder\Facades\FormBuilder;
FormBuilder::fake();
$this->post('/forms/contact', FormBuilder::validInput('contact', ['email' => 'ada@example.com', 'message' => 'Hi']));
FormBuilder::assertSubmitted('contact', fn ($submission, array $data) => $data['email'] === 'ada@example.com')
->assertSubmittedCount('contact', 1)
->assertNotSubmitted('newsletter');
FormBuilder::assertNothingSubmitted(); // nothing at all
FormBuilder::assertSpamDetected('contact', 'honeypot');
FormBuilder::submitted('contact'); // the recorded submissions
FormBuilder::validInput($form, $values) adds the protection token so a test post passes the time trap and the token check (set spam.min_seconds to 0 in tests).