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 MostValuableClientsThe 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:
| Hook | Signature | Default |
|---|---|---|
query | public function query(LensRequest, Builder): Builder|Paginator | abstract |
fields | public function fields(Request): array | [] |
cards | public function cards(Request): array | [] |
filters | public function filters(Request): array | [] |
actions | public function actions(Request): array | [] (controller falls back to resource actions) |
name | public function name(): string | title-case of class basename minus Lens suffix |
uriKey | public function uriKey(): string | kebab-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): anorWhere()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 lastoronly.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:
| Member | Lens default | Inheritance rule |
|---|---|---|
filters(Request) | [] | Inherited when the lens did not override the method. |
actions(Request) | [] | Inherited when the lens did not override the method. |
$perPageOptions | null | Inherited from Resource::perPageOptions() when null. |
$perPage | null | Inherited 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
cacheForto 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:
| Getter | Returns |
|---|---|
cacheTtl(): int | TTL in seconds (0 when caching is disabled). |
pollingEnabled(): bool | Whether the lens auto-refreshes the index payload. Reads static::$polling. |
pollingInterval(): int | Refresh cadence in seconds. Clamped at the same five-second floor as the resource. |
pollingToggleVisible(): bool | Whether 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
| Method | URL | Controller |
|---|---|---|
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.