Documentation menu
HomeDocsCore ConceptsLenses

Lenses

Lenses are alternative, resource-scoped views onto the dataset of a Martis Resource. Every lens is a subclass that composes its own query, fields, filters, cards, actions and optional polling cadence. Martis adds a small set of extensions that are marked below.

Quick start

Generate a lens with the artisan command:

php artisan martis:lens MostValuableClients

The generator creates app/Martis/Lenses/MostValuableClients.php with the following minimum surface:

namespace App\Martis\Lenses;
 
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\Request;
use Martis\Http\Requests\LensRequest;
use Martis\Lenses\Lens;
 
class MostValuableClients extends Lens
{
public function query(LensRequest $request, Builder $query): Builder
{
return $request->withOrdering(
$request->withFilters($query),
fn (Builder $q) => $q->latest(),
);
}
 
public function fields(Request $request): array
{
return [/* ... */];
}
}

Register it on a Resource:

public function lenses(Request $request): array
{
return [
new MostValuableClients(),
];
}

The admin UI gets a "Lenses" dropdown on the resource index. Selecting the lens navigates to /resources/{resource}/lens/{lens}.

Core contract

The base class exposes the following contract:

HookSignatureDefault
querypublic function query(LensRequest, Builder): Builder|Paginatorabstract
fieldspublic function fields(Request): array[]
cardspublic function cards(Request): array[]
filterspublic function filters(Request): array[]
actionspublic function actions(Request): array[] (controller falls back to resource actions)
namepublic function name(): stringtitle-case of class basename minus Lens suffix
uriKeypublic function uriKey(): stringkebab-case of class basename minus Lens suffix

perPage and perPageOptions are static methods (identical shape to Resource::perPage() / Resource::perPageOptions()). Polling knobs are static properties:

public static function perPageOptions(): array
{
return [10, 25, 50, 100];
}
 
public static function perPage(): int
{
return 50;
}
 
public static bool $polling = true;
public static int $pollingInterval = 60;
public static bool $showPollingToggle = true;

Effective per-page

Lenses follow the same clamp rule as resources: when the value returned by perPage() is not in perPageOptions(), Martis silently clamps to perPageOptions()[0]. The clamped value is what the lens schema payload (meta.perPage) actually exposes to the UI.

public static function perPageOptions(): array { return [10, 25, 50, 100]; }
public static function perPage(): int { return 7; } // not in options
 
// Lens::resolvedPerPage() === 10 — clamped to first option.

Use Lens::resolvedPerPage(): int when the backend needs the single "effective" per-page value; never call perPage() directly if you want the clamped result. See resources.md — Effective per-page for the full ruleset (Rules 1–3 apply identically here).

Query composition helpers

LensRequest exposes two composition helpers:

  • withFilters(Builder): Builder — applies filter values selected by the user. Each filter runs as Eloquent runs a local scope (v2.0.1): an orWhere() in the lens's query (where('status', 'open')->orWhere('shared', true)) and one in a filter stay inside their own group, so the filter narrows every record the lens's query keeps. Before v2.0.1 the filter's clause was appended to the query's last or only.
  • withOrdering(Builder, ?Closure $default = null): Builder — applies the user's chosen sort (one column or up to three, ?sort=a:asc,b:desc), then the primary key ascending; if none, calls the default closure.
public function query(LensRequest $request, Builder $query): Builder
{
return $request->withOrdering(
$request->withFilters($query),
fn (Builder $q) => $q->orderByDesc('monthly_revenue'),
);
}

The lens lists what the index lists

Changed in v2.4.0 (breaking).

A lens starts from the query of the resource's index: the resource's declarative scopes(), then its indexQuery() (the tenant / ownership fence), run on the model's query before Lens::query() receives it, in the index's order and grouped as the index groups them. The lens page, its pagination and total, its summary and the records its actions run on all start from that query, so a lens that does not repeat the constraint never lists another tenant's rows, field values or summary aggregates, and a lens action never runs on them. A record the fence hides answers 404 on a lens action, as it does on the resource route.

The lens's own clauses run grouped, as a filter does: an orWhere() in query() (where('status', 'open')->orWhere('shared', true)) cannot OR the fence away, so the query reads fence AND (status = 'open' OR shared). A query() that returns a Paginator (it ends in ->paginate()) runs its SQL inside the lens, before anything can group its clauses, so the fence also rides on the query as a global scope that Eloquent ANDs around them at execution: the same fence AND (...) holds there. That scope is a global scope like any other: a lens that calls withoutGlobalScopes() with no argument on the query it paginates removes it (remove named scopes instead, withoutGlobalScope(SoftDeletingScope::class)), and a lens that opts out of the fence has none.

The fence confines the query the lens is handed. A lens that ignores $query and starts from Model::query() builds its own query and is outside the fence: always start from $query, as the example below does.

class TenantResource extends Resource
{
public static function scopes(Request $request): array
{
return ['tenant' => fn (Builder $q) => $q->where('tenant_id', $request->user()->tenant_id)];
}
}
 
class OverdueInvoicesLens extends Lens
{
// Lists the overdue invoices of the user's tenant: no tenant constraint
// to repeat.
public function query(LensRequest $request, Builder $query): Builder
{
return $request->withFilters($query->where('due_at', '<', now()));
}
}

A lens that aggregates across the fence on purpose (a cross-tenant report for staff) opts out with a static property. It then starts from the bare model query, as lenses did before v2.4.0, and confines its own query:

class AllTenantsRevenueLens extends Lens
{
public static bool $withoutIndexScope = true;
 
public function query(LensRequest $request, Builder $query): Builder
{
return $query->orderByDesc('revenue');
}
}

The opt-out is per lens class (it is read statically) and covers the lens page, its summary and its actions together. Authorize such a lens with canSee() or canSeeWhen(): it is the only fence left.

Which columns sort a lens

A lens is sorted only by its own sortable fields the user can see: the attributes of the sortable() fields its fields() declares (layout containers opened) whose canSee() allows the user (v1.38.0). A ?sort= naming any other column is dropped before the lens runs: sortColumn is null, as if the request named none, so withOrdering() calls the default closure. That covers a column no field of the lens exposes, a field the user cannot see (the order of the rows would tell the order of its values) and a column that does not exist, which never reaches the query. The same holds for a lens that reads $request->sortColumn itself instead of calling withOrdering().

Sorting a lens by several columns

?sort= takes a list, ?sort=name:asc,monthly_revenue:desc (at most 3 keys, a missing direction is asc, a lone ?sort=name&direction=desc still reads). Every key that is not a sortable field of the lens the user can see is dropped; the others apply. withOrdering() orders by the remaining keys, then by the primary key ascending, and a field with sortUsing() orders through its closure. The lens has no defaultSort(): with no valid key the default closure of withOrdering() orders it.

$request->sortKeys lists the keys (Martis\Support\SortKey, an attribute and a direction), $request->sortColumn and $request->sortDirection are the first one. A lens that orders the query itself should loop over sortKeys (and honour a field's sortCallback() through $request->sortableFields) or call withOrdering().

The header of a sortable column sorts by itself on a click and adds the column on Shift+click (Shift+Enter), as on the index; the lens keeps the list in the URL (?sort=name:asc,monthly_revenue:desc, no direction).

public function fields(Request $request): array
{
return [
Text::make('name')->sortable(), // ?sort=name orders the lens
Currency::make('monthly_revenue')->sortable()
->canSee(fn (Request $request) => $request->user()?->isAdmin() ?? false),
// ?sort=monthly_revenue orders the lens for an admin only;
// ?sort=internal_score (no field) never does.
];
}

Before v1.38.0 withOrdering() ordered the lens by whatever column ?sort= named: a column no field exposes or a field the user cannot see ordered the rows by values the user may not read, and a column that does not exist answered 500 on MySQL / PostgreSQL.

Searching a lens

The lens's search box sends ?search=, which reaches the lens as $request->search; Martis does not match it on anything itself, so the lens decides which columns it searches. Search the columns the user may see: a term matched on a column a field hides from the user (canSee()) tells which records hold it. The resource index, by contrast, matches only the searchable() fields the user can see (see Fields → Field authorization).

public function query(LensRequest $request, Builder $query): Builder
{
if ($request->search !== '') {
$query->where('name', 'like', '%'.$request->search.'%');
}
 
return $request->withOrdering($request->withFilters($query), fn (Builder $q) => $q->latest());
}

Inheritance from the parent resource

When the lens does not declare its own override, it inherits the value from the parent Resource:

MemberLens defaultInheritance rule
filters(Request)[]Inherited when the lens did not override the method.
actions(Request)[]Inherited when the lens did not override the method.
$perPageOptionsnullInherited from Resource::perPageOptions() when null.
$perPagenullInherited from Resource::perPage() when null.

Override semantics: whenever the lens does override the hook, its return value wins verbatim — including an explicit empty array. That is how you disable filters on a lens:

public function filters(Request $request): array
{
return []; // the resource's filters are intentionally suppressed
}

Actions only the lens declares

A lens that overrides actions() runs its own list, as in Nova: the lens page reads the actions' fields and pickers and runs them through /martis/api/resources/{resource}/lenses/{lens}/actions/... (see API → Actions), so an action the resource does not declare works on the lens, and a resource action the lens left out does not run from it. The lens's canSee() gates those routes; the action's canSee() / canRun() and the run-action policy apply as on the index, and each lens row carries _actionAuthorization for the lens's actions.

The selected records are the ones the lens lists, as Nova's LensActionRequest reads them: Lens::query() runs on a query of the resource's model, with the lens's filters the user may see (from the request's ?filters=) and its search, and the selected ids are kept only when it returns them. A record the lens does not list answers 404, like a record the index hides on the resource route: the query starts from what the resource's index lists (its scopes() and indexQuery(), see The lens lists what the index lists), as the lens page's does. The action receives whole records read by key from the model's table, so a lens that selects aggregates, joins or groups still hands its actions real models. A lens whose query() returns a paginator cannot run actions on records: the run throws a LogicException naming the lens (return a query instead). Trashed records the lens lists are included. Before v2.0.1 the lens page ran its actions through the resource's routes, which only know the resource's actions(): an action only the lens declared answered 404.

The controller uses PHP reflection (Lens::hasOverride(string $method)) to tell an implicit fallback apart from a deliberate empty return.

Trashed (soft-delete) filtering is honoured automatically: if the parent resource uses SoftDeletes, the lens page surfaces the same Active / With trashed / Only trashed selector as the index, and the ?trashed= query param is applied to the base query before the lens runs its own query().

Opting individual filters out of lens inheritance

A Filter can call ->excludeFromLens() to signal "this filter is only for the default index; don't inherit it into lenses". Works alongside the automatic inheritance — lenses that override filters() are unaffected.

public function filters(Request $request): array
{
return [
// Appears on the index AND every inheriting lens.
StatusFilter::make('Estado', 'status'),
 
// Appears only on the index — lenses inheriting the resource's
// filters skip this one.
InternalNotesFilter::make('Notas', 'notes')->excludeFromLens(),
];
}

Summary aggregation

The summary() hook receives the lens-filtered query — the same query Lens::query() composes, including user filters and search — not the untouched base query. So a summary over OverdueInvoices really does sum only overdue rows, even when the invoices table holds thousands of non-matching rows.

If Lens::query() returns a Paginator directly, the summary is skipped (the developer has opted into manual pagination and is expected to derive totals themselves).

Authorization

  • canSee(Closure $callback) — closure that receives the request and returns bool.
  • canSeeWhen(string $ability, mixed $args = null) — shorthand delegating to $user->can($ability, $args).

When the closure denies, the lens is stripped from the schema and a direct GET to /resources/{r}/lenses/{lens} responds with HTTP 403. See authorization.md for the broader policy contract.

A lens soft-locked for the user (lockedFor(), requirePlan(), see Soft-gates) answers 403 with its lock payload on its page and on its action routes, and its filters that are locked are not applied (v2.4.0; see What a lock stops on the server).

Martis extensions

Sticky summary row

Martis ships a built-in summary row; no separate Metric card is needed for totals.

Return aggregates from summary() and the UI renders them as a sticky row below the lens table:

public function summary(Request $request, Builder $query): array
{
return [
'clients' => ['label' => 'Clients', 'value' => $query->count()],
'revenue' => ['label' => 'Monthly Revenue', 'value' => $query->sum('monthly_revenue')],
];
}

Served inside the paginated response meta as meta.summary[key] = { label, value, format? }.

Declarative query cache (with auto-invalidation)

Pass cacheFor to stop paying the cost of heavy joins on every pageload.

public function lenses(Request $request): array
{
return [
(new MostValuableClients())->cacheFor(60), // seconds
(new MostValuableClients())->cacheFor(new DateInterval('PT5M')),
];
}

The cache key mixes the lens uriKey, active filters, search, the sort keys (columns and directions), page, trashed mode and a per-table signature — COUNT(*) + MAX(updated_at) taken right before the cache lookup. Any insert, update or delete on the model bumps the signature, so the next request automatically misses the cache without any observers or cache tags. TTL 0 (default) disables the cache.

The updated_at column is the model's own (getUpdatedAtColumn()). A model that keeps no timestamps (public $timestamps = false, or const UPDATED_AT = null) is signed by its row count alone, so an update that keeps the count serves the cached rows until the TTL expires. The signature is taken for a cached lens only (v1.38.0).

The key also carries the signed-in user's id and the locale, nothing else of the request. The index fence (scopes(), indexQuery()) is read when the lens runs and is not part of the key: a lens with cacheFor() whose fence depends on request state other than the user (an active tenant kept in the session, a header) would serve the rows cached under the previous tenant to the same user until the TTL expires. Register the tenant with Martis::cacheScopeUsing() (v2.10.0: what the resolver answers is added to the key, see Scoping the keys by host or tenant), or do not cache such a lens (cacheFor(0)), or make its fence depend on the user alone. Before v1.38.0 every lens request ran MAX(updated_at), the cache off included, so a lens over a table without that column answered 500.

Default filters pre-applied

Declare the filters the lens should open with so product dashboards open in a useful state.

(new MostValuableClients())
->withDefaultFilters(['plan' => 'enterprise']);

The first load hydrates the URL with those values. If the user clears them manually, the empty state is respected; the defaults do not re-populate.

Each lens hydrates its own defaults, also when the page moves to it from another lens through the lens dropdown, and starts with no selection and no drawer or confirmation of the previous lens open (v1.38.0+). Before v1.38.0 only the first lens the page opened applied its defaults: the router kept the page when the URL moved to another lens, so that lens loaded without its own, under the previous lens's selection.

Use Lens::defaultFilters(): array to read the configured map back — useful for diagnostics or to forward the defaults into another lens.

Inspecting the cache and polling state

Pair cacheFor() and the polling static properties with the matching getters when consumer code needs to query the resolved values:

GetterReturns
cacheTtl(): intTTL in seconds (0 when caching is disabled).
pollingEnabled(): boolWhether the lens auto-refreshes the index payload. Reads static::$polling.
pollingInterval(): intRefresh cadence in seconds. Clamped at the same five-second floor as the resource.
pollingToggleVisible(): boolWhether the toolbar exposes the pause/resume affordance.

URL state sync (frontend)

All lens view state — search, filters, sort (the list of keys), page — lives in the URL query string. Copy-paste a URL and you reproduce the exact view. Reloading the page restores the view from the URL, no local storage involved.

Registering custom components

->componentKey('custom-lens-card') lets the frontend swap out the default table entirely, as long as the custom component is registered in the component registry and can consume the paginated payload.

Routing reference

MethodURLController
GET/martis/api/resources/{resource}/lenses/{lens}LensController@index

The frontend route is /martis/resources/{resource}/lens/{lens} (UI tree), powered by ResourceLensPage.

Payload shape

The lens endpoint returns a standard Martis paginated envelope plus a rich meta block that lets the UI render the lens chrome without a second round-trip:

{
"data": [/* records serialized with the lens-declared fields */],
"meta": {
"current_page": 1, "last_page": 1, "per_page": 25,
"from": 1, "to": 9, "total": 9,
"fields": [/* FieldDefinition[] as declared by the lens */],
"actions": [/* ActionMeta[], inherited from the resource unless the lens overrides */],
"perPageOptions": [25, 50, 100],
"polling": true, "pollingInterval": 60, "showPollingToggle": true,
"defaultFilters": {"plan": "enterprise"},
"summary": {/* only when Lens::summary() returned values */}
},
"links": {"first": "...", "last": "...", "prev": null, "next": null}
}

meta.fields is the authoritative source for the index columns. The UI must not fall back to schema.fieldsForIndex — that would leak the resource's columns into the lens view.

Test harness

./vendor/bin/pest --filter="LensControllerTest|LensDifferentialsTest"

The package ships 17 tests covering every Lens method and every differential. Add your own specs that subclass Martis\Lenses\Lens — the only hard requirement is a concrete query() method.