Skip to content

Lookups ​

Lookups provide browse-and-select functionality for fields. When a user presses Ctrl+Enter on a lookup field (alternates: Shift+Enter, F6, Ctrl+O), the client requests the lookup endpoint and displays a list to choose from.

Reusable Lookup Classes ​

For lookups used across multiple resources (country, postal code, currency), define a lookup class once and reference it everywhere.

Scaffold one with artisan:

bash
php artisan 2wee:lookup CountryLookup --model=Country
php
// app/TwoWee/Lookups/CountryLookup.php
class CountryLookup
{
    public static function definition(): LookupDefinition
    {
        return LookupDefinition::make(Country::class)
            ->title('Countries')
            ->columns([
                TextColumn::make('code')->label('Code')->width(15),
                TextColumn::make('name')->label('Name')->width('fill'),
            ])
            ->valueColumn('code');
    }
}

Then reference it on any field or column:

php
Text::make('country_code')
    ->uppercase()
    ->lookup(CountryLookup::class)
    ->modal()

The plugin detects the lookup class, calls ::definition(), and registers it automatically. No lookups() method needed.

Field-level ->autofill() merges with the definition's autofill — so you can add resource-specific mappings:

php
Text::make('post_code')
    ->lookup(PostCodeLookup::class)
    ->filterFrom('country_code')
    ->autofill(['name' => 'city'])     // merged with PostCodeLookup's autofill

Reusable field groups ​

Combine lookup classes with PHP array spreading for reusable field blocks:

php
// app/TwoWee/FieldGroups/AddressFields.php
class AddressFields
{
    public static function make(): array
    {
        return [
            Text::make('country_code')->label('Country')->width(10)
                ->uppercase()->lookup(CountryLookup::class)->modal(),
            Text::make('post_code')->label('Post Code')->width(10)
                ->lookup(PostCodeLookup::class)->filterFrom('country_code'),
            Text::make('city')->label('City')->width(30)
                ->quickEntry(false),
        ];
    }
}

// In any resource
Section::make('Address')->fields([
    ...AddressFields::make(),
    Text::make('attention')->label('Attention'),
])

Manual Lookup Definitions ​

For advanced cases or one-off lookups, define them in the resource's lookups() method:

php
use TwoWee\Laravel\Lookup\LookupDefinition;
use TwoWee\Laravel\Columns\TextColumn;

public static function lookups(): array
{
    return [
        'customer_no' => LookupDefinition::make(\App\Models\Customer::class)
            ->columns([
                TextColumn::make('no')->label('No.')->width(10),
                TextColumn::make('name')->label('Name')->width('fill'),
                TextColumn::make('city')->label('City')->width(20),
            ])
            ->valueColumn('no')
            ->autofill(['city', 'name' => 'customer_name']),
    ];
}

The field_id key ('customer_no') must match the field's lookup endpoint.

LookupDefinition API ​

php
LookupDefinition::make($modelClass)
    ->columns([...])           // Column definitions for the lookup list
    ->valueColumn('no')        // Which column value is returned to the field
    ->autofill([...])          // Map lookup columns to card fields
    ->query(fn($q, $context) => $q->where('active', true))  // Custom query filter
    ->display('modal')         // 'modal' or null (full-screen)

Autofill ​

When the user selects a lookup row, the client auto-fills other fields using the autofill map.

Use a simple array when the lookup column name and the target field name are the same (the common case):

php
->autofill(['address', 'city', 'phone'])

When the names differ, use a key-value pair:

php
->autofill(['name' => 'customer_name'])

Mix both in one call:

php
->autofill(['address', 'city', 'name' => 'customer_name', 'phone' => 'phone_no'])
// 'address' fills 'address', 'city' fills 'city' — names match, no mapping needed
// 'name' fills 'customer_name', 'phone' fills 'phone_no' — names differ, explicit mapping

The client ignores autofill keys that don't match a field on the current screen — a superset is safe.

Lookups can display in two modes:

Full-screen (default) ​

The lookup opens as a full List screen with server-side pagination. Best for large datasets (customers, items, ledger entries).

php
// Full-screen — default, no extra config needed
Text::make('customer_no')
    ->lookup(Customer::class, valueColumn: 'no')
    ->autofill(['name' => 'customer_name'])

The server returns a page of results (default 50 rows) sorted by the value column. Three request modes:

ParameterBehavior
?selected=C-74428Start from the selected value's position — opens with the current record at the top
?query=acmeSearch mode — first 50 matches across all columns
(no params)First 50 rows sorted by value column

The client sends ?selected= with the current field value when opening the lookup, so the list opens at the right position. This handles millions of rows without loading everything.

Page size ​

The default page size is 50. Change it globally in config:

php
// config/twowee.php
'lookup' => [
    'page_size' => 100,
],

Or per lookup:

php
LookupDefinition::make(Customer::class)
    ->pageSize(100)
    ->columns([...])
    ->valueColumn('no')

The user types a search term, the client sends GET /lookup/customer_no?query=acme, and the server returns matching rows.

The lookup opens as an inline overlay on the current screen. All rows load at once — the client filters locally without server round-trips. Best for small datasets (postal codes, currencies, payment terms, country codes).

php
// Modal — add ->modal()
Text::make('post_code')
    ->lookup(PostCode::class, valueColumn: 'code')
    ->modal()
    ->filterFrom('country_code')
    ->autofill(['name' => 'city'])    // 'name' column fills the 'city' field

Text::make('currency_code')
    ->lookup(CurrencyCode::class, valueColumn: 'code')
    ->modal()
    ->autofill(['description' => 'currency_name'])   // names differ

Text::make('payment_terms_code')
    ->lookup(PaymentTerm::class, valueColumn: 'code')
    ->modal()
    ->autofill(['description' => 'payment_terms'])   // names differ

How modal works ​

  1. User presses Ctrl+Enter on the field (alternates: Shift+Enter, F6, Ctrl+O)
  2. Client sends GET /lookup/post_code (no ?query= parameter)
  3. Server returns all rows
  4. Client renders them in an overlay on top of the current card
  5. User types to filter — client filters locally (fuzzy matching)
  6. User selects a row — value and autofill applied, overlay closes

When to use which ​

ModalFull-screen
Dataset sizeSmall (< ~100 rows)Large
SearchClient-side filteringServer-side ?query=
LoadingAll rows at oncePaginated/filtered
UXStays on the cardNavigates to a list screen
Use forPostal codes, currencies, payment termsCustomers, items, vendors

Manual endpoint form ​

php
// Using display parameter directly
Text::make('terms')
    ->lookup('payment_terms', 'description', validate: true, display: 'modal')

Drill-Down from Lookups ​

When a lookup model has a registered TwoWee resource, the lookup response automatically includes a drill-down URL. This lets users inspect a record before selecting it:

  1. User opens the customer lookup (Ctrl+Enter or F6)
  2. Sees the list of customers
  3. Presses Ctrl+Enter on a row to open the full customer card
  4. Reviews the card, presses Esc to return to the lookup
  5. Presses Enter to select the customer

This is automatic — no configuration needed. The plugin detects that the lookup model (e.g. Customer) has a registered resource (CustomerResource) and adds the card URL to the response.

For manual LookupDefinition, you can set it explicitly:

php
LookupDefinition::make(Customer::class)
    ->columns([...])
    ->valueColumn('no')
    ->onDrill('/terminal/screen/customers/card/{0}')

{0} is replaced with the first column value (the record ID).

Blur Validation ​

Fields with validate: true on their lookup trigger async validation when the user leaves the field:

php
Text::make('customer_no')
    ->uppercase()
    ->lookup('customer_no', 'customer_name', validate: true)

The client calls GET /validate/customer_no/{value} and receives:

json
{ "valid": true, "autofill": { "customer_name": "ACME Corp" }, "error": null }

or on failure:

json
{ "valid": false, "autofill": null, "error": "Customer not found." }

Context-Dependent Lookups ​

A lookup can depend on other field values. Add ->filterFrom() to send those values as query parameters:

php
// Post code filtered by country
Text::make('post_code')
    ->lookup('post_code', null, validate: true)
    ->filterFrom('country_code')

When the user opens the lookup or tabs away, the client reads country_code from the card and sends:

GET /lookup/post_code?country_code=FO
GET /validate/post_code/100?country_code=FO

Multiple context fields ​

php
Text::make('post_code')
    ->lookup('post_code', null, validate: true)
    ->filterFrom('country_code', 'region_code')

→ GET /lookup/post_code?country_code=FO&region_code=ST

Aliased parameter names ​

When the query parameter name differs from the field ID:

php
Text::make('account_no')
    ->lookup('account_no')
    ->filterFrom(['field' => 'account_type', 'param' => 'type'])

→ GET /lookup/account_no?type=Customer

Server-side handling ​

The query modifier receives all context as an associative array:

php
LookupDefinition::make(\App\Models\PostCode::class)
    ->query(function ($query, array $context) {
        if (! empty($context['country_code'])) {
            $query->where('country_code', $context['country_code']);
        }
    })

For polymorphic lookups (grid line items):

php
LookupDefinition::make(\App\Models\Item::class)
    ->query(function ($query, array $context) {
        $type = $context['type'] ?? null;
        if ($type === 'Resource') {
            $query->from('resources');
        }
    })

If the context field is empty, the client omits the parameter. Your query modifier receives an empty or missing key — handle accordingly.

Drill-Down ​

Drill-down uses the same lookup infrastructure. Any field with ->disabled()->drillDown():

php
Decimal::make('balance')
    ->disabled()
    ->drillDown('ledger_entries')
    ->color(Color::Yellow)->bold()

The GET /drilldown/ledger_entries/{key} endpoint returns a read-only List screen.