NewWe open-sourced 50+ Laravel packages
Custom AI apps, agents and automation — Roundly ConsultingRoundly
All packages
Tickets for Laravel

Custom fields

Admins define fields in the database (ticket_fields) — globally, per project or per category; the values live in attributes-for-laravel, one per (ticket, key). define(FieldData) is the DTO form of the builder; both run DefineFieldAction, and builder setters validate eagerly:

use RoundlyConsulting\Tickets\DataTransferObjects\UpdateFieldData;
use RoundlyConsulting\Tickets\Enums\FieldType;

Tickets::fields()->make('asset_tag', FieldType::Text)
    ->label(['en' => 'Asset tag', 'sk' => 'Inventárne číslo'])
    ->help(['en' => 'On the sticker'])
    ->category($hardware)          // or ->project($helpdesk); neither = global
    ->required()
    ->requesterEditable()          // the requester may fill it (implies requester-visible)
    ->rules(['max:32'])
    ->define();

Tickets::fields()->make('root_cause', FieldType::Select)
    ->options(['hardware' => ['en' => 'Hardware'], 'user' => ['en' => 'User error']])
    ->agentOnly()                  // requester_visible = false, requester_editable = false
    ->define();

Tickets::fields()->update($field, new UpdateFieldData(required: false, position: 5));
Tickets::fields()->delete($field);    // soft: values stay hidden until a restore or a prune
Tickets::fields()->restore($field);   // refused when the key was taken meanwhile
AttributeMeaning
key^[a-z][a-z0-9_]{0,63}$; immutable. Unique among live fields per project counting global fields and every category — a child category cannot re-define an ancestor’s or a global key. A key a deleted field of the scope still keeps values under is refused too (field.kept_values): restore that field, or prune its values first. Enforced under the lock:fields advisory row.
label, helpTranslatable locale maps (a string = the current locale).
typeA FieldType (below).
optionsselect / multiselect: value => label (a locale map or a string).
rulesExtra rule strings from tickets.fields.allowed_rules.
required, default, encryptedThe default must be a value the field itself accepts (type, options, rules) — checked at define() and on every update; a ValidationException keyed default (HTTP 422) otherwise.
requester_visible, requester_editableWhat the requester side sees and may set.
position, is_activeOrder; an inactive field is not offered, its values are kept.
project, category_idImmutable scope.

Field types

FieldTypeStored asBase rules
textstringstring, max:255
textareastringstring, max:65535
emailstringstring, email:rfc
urlstringstring, url
numberintegerinteger
decimalfloatnumeric
booleanbooleanboolean
date, datetimedatetimedate
selectstringstring, in:<options>
multiselectarrayarray, each in:<options>

Rules are allow-listed (tickets.fields.allowed_rules): exists and unique are always refused (no database probing), closures and objects too, and a regex: pattern is capped at 255 characters and must compile. Once values exist the type and the encryption flag are locked (FieldTypeLockedException); select options may then only grow, and a multiselect keeps every option in use.

The applicable set

A ticket’s fields are the active fields whose project is the ticket’s (or global) and whose category is null or in the ticket category’s ancestor chain, ordered by position. Fields\FieldSetResolver (scoped per request or job, Octane-safe) memoises the set per (project, category):

Tickets::fields()->for($ticket);              // array<string, FieldDefinition>, keyed by field key
Tickets::fields()->for($category, $project);  // for a ticket that would be filed there
Tickets::for($ticket)->fields()->definitions();

Tickets::fields()->find('asset_tag', $project); // ?TicketField by key or id: the project's, else the global one

Values

Tickets::for($ticket)->by($agent)->fields()->set('asset_tag', 'PRN-0042')->setMany(['root_cause' => 'hardware']);
Tickets::for($ticket)->fields()->get('asset_tag');   // the default when unset; null for an unknown key
Tickets::for($ticket)->fields()->all();              // ['asset_tag' => 'PRN-0042', …], field order
Tickets::for($ticket)->by($agent)->fields()->forget(['root_cause']);

Tickets::make()->subject('…')->requester($user)->category($hardware)->fields(['asset_tag' => 'PRN-0042'])->create();
  • set($key, null) clears a value; clearing or forgetting a required field is refused (ValidationException).
  • A key that is no field of the ticket throws FieldNotEditableException (unknown_field); a non-agent actor may set only requester_editable fields (field_not_editable).
  • Each value is validated against its field (ValidationException keyed fields.<key>), and the message names the field by its label in the current locale.
  • Required fields are enforced at creation, when a category change brings a newly required field, and when an agent or the system writes field values — each time only what the actor may write is owed, so a required agent-only field can’t block self-service.
  • The fields_changed activity entry (internal) and TicketFieldsChanged carry the keys changed — never a value, so encrypted values never reach the timeline, events or logs.
  • Values of a deleted field, or of a field that no longer applies after a category change, are kept but hidden from reads; they never resurface under another field.

Visibility

Requester and guest resources show only requester_visible fields. Encrypted values are decrypted for agents; a requester sees one only if it is requester_visible.

Filtering

Ticket::query()->whereField('asset_tag', 'PRN-0042')->get();
Ticket::query()->whereFieldBetween('estimate_hours', 2, null)->get();   // one-sided: the open end is padded
  • The value is cast to the field’s type first (a request string "42" matches an integer row); an uncastable value matches nothing.
  • Both bounds are inclusive. A datetime field’s upper bound given as a date (2026-10-17) runs to the end of that day.
  • An unknown, encrypted or multiselect key throws UnknownTicketFieldException.
  • In the API: filter[fields.<key>]=a,b and, for number, decimal, date and datetime fields, filter[fields.<key>.from] / filter[fields.<key>.to] — built per request from the active, unencrypted, non-multiselect fields the viewer may see.

Pruning orphaned values

$count = Tickets::fields()->prune(dryRun: true);      // count only
$count = Tickets::fields()->prune(chunk: 500);        // forget them, 500 tickets at a time
php artisan tickets:prune-fields --dry-run
php artisan tickets:prune-fields --chunk=500

Pruning force-deletes the values no field of their ticket accounts for any more — a deleted field, a category the ticket left. Values of an inactive field are kept. This is the one step that cannot be undone; until then a restore brings the values back, and defining a new field with a deleted field’s key is refused while such values remain.

Show your open-source love

This package is free and MIT-licensed. If it saves you time, a one-off donation or a Patreon membership keeps it maintained, tested and documented.

More ways to support, including crypto

By donating, you agree to our donation terms.

Want this built into your product?

We integrate our packages into custom Laravel and AI builds. Tell us what you're working on and we'll reply within 48 hours.