---
title: "Custom fields — Tickets for Laravel | Roundly"
description: "Typed fields globally, per project or per category, with allow-listed rules, encryption and requester visibility — set, read, filter and prune values."
url: https://roundly-consulting.com/open-source/docs/tickets-for-laravel/custom-fields
language: en
---

[All packages](https://roundly-consulting.com/open-source.md)

[Tickets for Laravel](https://roundly-consulting.com/open-source/docs/tickets-for-laravel.md)

# 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:

```php
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
```

| Attribute | Meaning |
| --- | --- |
| `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, help` | Translatable locale maps (a string = the current locale). |
| `type` | A FieldType (below). |
| `options` | select / multiselect: value => label (a locale map or a string). |
| `rules` | Extra rule strings from tickets.fields.allowed\_rules. |
| `required, default, encrypted` | The 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_editable` | What the requester side sees and may set. |
| `position, is_active` | Order; an inactive field is not offered, its values are kept. |
| project, category\_id | Immutable scope. |

## Field types

| FieldType | Stored as | Base rules |
| --- | --- | --- |
| `text` | string | `string, max:255` |
| `textarea` | string | `string, max:65535` |
| `email` | string | `string, email:rfc` |
| `url` | string | `string, url` |
| `number` | integer | `integer` |
| `decimal` | float | `numeric` |
| `boolean` | boolean | `boolean` |
| `date, datetime` | datetime | `date` |
| `select` | string | `string, in:<options>` |
| `multiselect` | array | array, 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):

```php
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

```php
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

```php
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

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

```bash
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](https://roundly-consulting.com/support-us.md)

By donating, you agree to our [donation terms](https://roundly-consulting.com/donation-terms.md).

[Support our open source work (opens in a new tab)](https://donate.stripe.com/dRmeVe8FX5PF1Qd9pXcEw00) [Join us on Patreon (opens in a new tab)](https://www.patreon.com/cw/roundly)

## 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.

[Get a quote in 48 hours](https://roundly-consulting.com/contact.md) [Browse all packages](https://roundly-consulting.com/open-source/docs/tickets-for-laravel.md)
