Skip to main content
zerotal

Resources

A Resource is a static-only class describing one model. Override the statics and methods you need; everything has a sensible default.

Nothing instantiates a resource — the class itself is the configuration, read by the panel when it builds a screen. That is why every member is static, and why a resource can be imported and inspected anywhere without constructing anything.

Scaffold one with the generator, which writes app/admin/<Name>Resource.ts:

bun zt make:admin-resource Post
class PostResource extends Resource {
  static model = Post;

  // Navigation
  static navigationIcon = "document";
  static navigationGroup = "Content";
  static navigationSort = 10;
  static navigationParentItem = "Blog"; // nest under another item's label
  static navigationBadgeColor = "primary";

  // Identity & data
  static slug = "posts"; // default: kebab-cased plural
  static label = "Post"; // default: from model name
  static pluralLabel = "Posts";
  static primaryKey = "id";
  static perPage = 15;
  static defaultSort = { column: "created_at", direction: "desc" };
  static eager = ["author"]; // relations to eager-load
  static recordTitleAttribute = "title"; // used by search / breadcrumbs

  // A live count badge in the sidebar
  static async navigationBadge() {
    return this.count((q) => q.where("status", "draft"));
  }

  static columns() {
    /* … */ return [];
  }
  static form() {
    /* … */ return [];
  }
  static infolist() {
    /* … */ return [];
  }
  static filters() {
    /* … */ return [];
  }
  static tabs() {
    /* … */ return [];
  }
  static groups() {
    /* … */ return [];
  }
  static relations() {
    /* … */ return [];
  }
}

A resource does nothing until the panel knows about it. Register it where the panel is configured:

Panel.register(PostResource);

Three of the statics are load-bearing in ways worth calling out. slug becomes the URL segment, so changing it changes every link to the resource — set it once and leave it alone. eager is the cure for a list page issuing one query per row: name the relations your columns read and they are loaded up front. recordTitleAttribute decides how a record names itself in breadcrumbs, global search results, and relation pickers, so a resource without a meaningful one stays hard to navigate even when every screen renders correctly.

Rather than assembling URLs by hand, ask the resource: indexUrl(base), recordUrl(base, id), createUrl(base), editUrl(base, id) and routePath() build them for you, so a resource can move under a parent or into a cluster without any of its links changing.

Authorization

Override can(ability, record?) to gate the built-in actions. Abilities used by the presets: create, update, delete, restore, forceDelete.

static can(ability: string, record?: AdminRecord) {
  return Gate.allows(ability, record ?? this.model);
}

Delegating to Gate is usually the whole implementation, and it is the reason to write policies once rather than per screen: the same rule then governs the list page's row actions, the form's save button, and the bulk actions without being restated in each. A denied ability hides the control rather than merely rejecting the request, so the panel never offers a button that cannot work.

Lifecycle hooks

HookWhen
mutateFormDataBeforeFill(data)Transform a record into form state before Edit fills.
mutateBeforeSave(data, mode)Transform validated form data just before create/update.
afterSave(record, mode)After a successful create/update (e.g. sync relations).

The first two are mirror images, and implementing them as a pair is what keeps a field stored differently from how it is edited working in both directions — splitting a stored fullName into two inputs on fill, rejoining them on save.

mutateBeforeSave runs after validation, so its input is already valid and its job is to shape data rather than sanitise it. afterSave receives the persisted record, which is the moment to sync related rows: the record has an id by then, including on create.

Both save hooks receive the mode, so one implementation can branch on "create" versus "edit" instead of duplicating the resource.

Next steps

  • Admin overview — the guide's front page and the rest of the sections.
  • Tables — the columns, filters, and tabs declared above.
  • Forms & Infolists — the fields the save hooks operate on.