User guide
Schemas and Infolists
Forms collect input. Tables help people find and change records. Infolists present a record without making it editable. inlayphp/schemas is the shared layout and evaluation laye…
This chapter explains how to build a read-only detail screen and how to use the same layout primitives inside a Form or an Infolist. It also explains the boundary between the PHP schema and the React/Vue renderer, so a team can create its own components without putting business rules in JavaScript.
Choose the right object
| Need | Use | Why |
|---|---|---|
| Collect or edit values | Inlay\\Forms\\Form |
Fields, defaults, validation, lifecycle hooks, and submit actions |
| Display a record | Inlay\\Infolists\\Infolist |
Read-only entries, formatting, images, aggregates, and copy actions |
| Arrange components | Inlay\\Schemas\\Components\\* |
Shared sections, grids, tabs, wizards, callouts, and responsive layout |
| Reuse a PHP rule | inlayphp/validation |
One server-side rule set for Forms, Resources, requests, and imports |
The schema package does not render HTML by itself. It serializes a small, versioned contract. The React or Vue adapter renders that contract and applies the application's theme. A schema callback may query or authorize on the server, but closures and service objects never cross the Inertia boundary.
Install the packages
The panel preset already brings the shared schema runtime through Forms and Resources. Install the packages explicitly when building a standalone detail page or a package that only needs read-only schemas:
composer require inlayphp/schemas inlayphp/infolists
# Choose one adapter.
npm install @inlayphp/infolists-react
# or
npm install @inlayphp/infolists-vueThe adapter packages are peers of the Inertia 3 adapter, React 19 or Vue 3, the Forms adapter, Actions adapter, Core, and Theme. In a normal Inlay panel application the installer adds those dependencies and Tailwind source paths for you.
Build an Infolist in PHP
An Infolist has a name, a list of components, a column count, and data. The
data should be an array; pass the original Eloquent model through record()
when an entry needs relationship aggregates or record-aware callbacks.
<?php
namespace App\\Inlay\\Resources\\Users;
use App\\Models\\User;
use Inlay\\Actions\\Action;
use Inlay\\Infolists\\Entries\\IconEntry;
use Inlay\\Infolists\\Entries\\ImageEntry;
use Inlay\\Infolists\\Entries\\RepeatableEntry;
use Inlay\\Infolists\\Entries\\TextEntry;
use Inlay\\Infolists\\Infolist;
use Inlay\\Schemas\\Components\\Section;
final class UserDetails
{
public static function make(User $user): Infolist
{
return Infolist::make('user.details')
->columns(2)
->record($user)
->data($user->toArray())
->schema([
Section::make('Profile')
->columns(2)
->schema([
TextEntry::make('name')
->label('Name')
->weight('semibold'),
TextEntry::make('email')
->label('Email')
->copyable(message: 'Email copied'),
IconEntry::make('email_verified_at')
->label('Verified')
->boolean(),
TextEntry::make('created_at')
->label('Joined')
->date('M j, Y'),
ImageEntry::make('avatar_url')
->label('Avatar')
->imageWidth(96)
->imageHeight(96)
->circular()
->alt('Profile photo'),
]),
Section::make('Account')
->columns(2)
->schema([
TextEntry::make('role')
->badge()
->color(fn (string $state): string => $state === 'admin' ? 'primary' : 'neutral'),
IconEntry::make('is_active')->boolean(),
TextEntry::make('orders_count')
->label('Orders')
->numeric(),
TextEntry::make('last_login_at')
->label('Last sign-in')
->since()
->placeholder('Never'),
]),
Section::make('Addresses')
->columnSpanFull()
->schema([
RepeatableEntry::make('addresses')
->columns(3)
->schema([
TextEntry::make('line1'),
TextEntry::make('city'),
TextEntry::make('country'),
]),
]),
]);
}
}data() is the complete read-only state. It can contain nested arrays, but
state paths must point to values that are safe to display. record() is
optional for ordinary values and required for relationship counts/aggregates.
Do not pass a model directly into data(); convert it with a resource or
toArray() and explicitly choose which attributes are public.
Present the Infolist from a controller
use App\\Inlay\\Resources\\Users\\UserDetails;
use App\\Models\\User;
public function show(User $user)
{
$this->authorize('view', $user);
return inertia('users/show', [
'user' => $user->only(['id', 'name']),
'details' => UserDetails::make($user),
]);
}The policy runs before the Infolist is serialized. If a value is sensitive,
leave it out of data() rather than relying on a frontend hidden condition.
Entries and presentation
The built-in entries cover the common read-only cases:
| Entry | Use it for | Useful methods |
|---|---|---|
TextEntry |
Text, dates, numbers, money, links, badges, prose | formatStateUsing(), date(), since(), numeric(), money(), url(), copyable() |
IconEntry |
Boolean and icon state | boolean(), true(), false(), icon(), color(), size() |
ImageEntry |
One or many safe image URLs | imageWidth(), imageHeight(), circular(), stacked(), limit(), alt() |
ColorEntry |
A color swatch | copyable(), tooltip() |
CodeEntry |
Escaped code or JSON | optional phiki/phiki server highlighting |
KeyValueEntry |
Metadata maps | keyLabel(), valueLabel(), placeholder() |
RepeatableEntry |
A nested list of objects | columns(), schema() |
Every entry supports the shared component vocabulary: label(),
statePath(), default(), placeholder(), helperText(), tooltip(),
color(), columnSpan(), visibleWhen(), hiddenWhen(), safe extra
attributes, prefix/suffix actions, and content slots.
use Inlay\\Infolists\\Entries\\TextEntry;
TextEntry::make('description')
->label('Description')
->statePath('profile.description')
->placeholder('No description')
->formatStateUsing(fn (?string $state): string => trim((string) $state))
->limit(180)
->tooltip('The public profile description');
TextEntry::make('website')
->url(fn (?string $state): ?string => $state ?: null)
->openUrlInNewTab()
->copyable(message: 'URL copied');Formatting is server-authoritative. The renderer receives the formatted safe
value and the metadata required for accessible presentation. Use the package
API instead of injecting HTML into a label. If rich content is necessary, use
the schema Text component's sanitized HTML/Markdown support or a reviewed
custom renderer.
Dates, numbers, and money
Keep raw values in the model and format them only for the detail view:
TextEntry::make('total')->money('USD');
TextEntry::make('quantity')->numeric(decimalPlaces: 0);
TextEntry::make('created_at')->dateTime('M j, Y H:i');
TextEntry::make('updated_at')->since();Closures are useful when formatting depends on the record, but the closure must return the type documented by the method. A malformed value fails during serialization rather than producing inconsistent React and Vue output.
Relationships and aggregates
For a persisted Eloquent record, a text entry can ask Laravel to resolve a relationship count or aggregate. The query remains on the server:
TextEntry::make('comments_count')
->label('Comments')
->counts('comments');
TextEntry::make('paid_total')
->label('Paid total')
->sum('orders', 'total')
->money('USD');Use the exact aggregate methods exposed by the installed package version and
keep relationship names allow-listed in application code. Aggregates require
Infolist::record($model) and a persisted model; they are not a replacement
for an authorization scope. Apply policy and tenant constraints before the
Infolist is built.
Shared layout components
The same layout primitives can contain form fields, infolist entries, or shared schema text. This makes an edit screen and a read-only screen visually consistent without duplicating CSS concepts:
use Inlay\\Schemas\\Components\\Fieldset;
use Inlay\\Schemas\\Components\\Grid;
use Inlay\\Schemas\\Components\\Group;
use Inlay\\Schemas\\Components\\Section;
use Inlay\\Schemas\\Components\\Tabs;
use Inlay\\Schemas\\Components\\Tab;
use Inlay\\Infolists\\Entries\\TextEntry;
$layout = [
Section::make('Contact')
->description('Public contact details')
->icon('user')
->columns(['default' => 1, 'md' => 2])
->collapsible()
->persistCollapsed()
->schema([
Grid::make(2)->schema([
TextEntry::make('first_name'),
TextEntry::make('last_name'),
]),
]),
Tabs::make('More information')
->id('user-details')
->persistTabInQueryString('details-tab')
->tabs([
Tab::make('Preferences')->schema([...]),
Tab::make('Audit')->schema([...]),
]),
Fieldset::make('Technical metadata')
->contained(false)
->schema([
Group::make('Metadata values')->schema([
TextEntry::make('metadata.request_id'),
]),
]),
];Available containers include Section, Grid, Group, Fieldset, Tabs,
Tab, Wizard, WizardStep, Callout, Flex, and EmptyState. A
container can define columns(), columnSpan(), columnSpanFull(),
dense(), gap(false), visible(), hidden(), header/footer actions, and
nested schema components.
Responsive layout
Prefer semantic responsive values over renderer-specific class names:
Section::make('Billing')
->columns(['default' => 1, 'md' => 2, 'xl' => 4])
->schema([
TextEntry::make('company')->columnSpan(['default' => 1, 'xl' => 2]),
TextEntry::make('tax_id'),
TextEntry::make('notes')->columnSpanFull(),
]);For reusable embedded components, use container-query keys such as @md and
call gridContainer(). React and Vue receive the same column contract, so a
theme or custom renderer does not need a second layout definition.
Conditions and state-aware content
There are two kinds of conditions:
- Server-authoritative callbacks such as
visible(fn (SchemaContext $context) => ...). Use these for permissions, tenant rules, and anything involving a model or service. - Safe browser expressions such as
visibleWhen()orContentExpression::state(). Use these for presentation changes based on already-serialized values.
use Inlay\\Schemas\\SchemaContext;
use Inlay\\Schemas\\Support\\ContentExpression;
use Inlay\\Schemas\\Components\\Text;
Section::make('Company details')
->visible(fn (SchemaContext $context): bool =>
$context->record?->isCompany() === true
)
->schema([
TextEntry::make('company_name'),
]);
Text::make('Selected plan')
->reactive(ContentExpression::state(
'plan.name',
'No plan selected',
)->title());The safe expression language supports bounded operations such as upper,
lower, title, trim, limit, number, and currency. It is not
JavaScript and is never evaluated with eval. Do not serialize authorization
decisions as browser conditions; resolve them in PHP.
React and Vue rendering
The PHP object is passed as an Inertia prop. The official adapters expose a single component with the same resource contract:
React
import { Infolist } from '@inlayphp/infolists-react'
import type { InfolistResource } from '@inlayphp/infolists-react'
export default function ShowUser({ details }: { details: InfolistResource }) {
return (
<Infolist
resource={details}
className="user-details"
emptyValue="Not provided"
/>
)
}Vue
<script setup lang="ts">
import { Infolist } from '@inlayphp/infolists-vue'
import type { InfolistResource } from '@inlayphp/infolists-vue'
defineProps<{ details: InfolistResource }>()
</script>
<template>
<Infolist :resource="details" class-name="user-details" empty-value="Not provided" />
</template>Both adapters render the same inlay.infolists.v1 contract, support shared
theme tokens, and expose data-slot hooks. An application-owned page wrapper
can add breadcrumbs, a panel layout, or a header without changing the PHP
Infolist.
Actions and slots
Read-only does not mean non-interactive. Entries and containers can expose safe actions that use the Actions package:
use Inlay\\Actions\\Action;
use Inlay\\Schemas\\Components\\Section;
Section::make('Profile')
->headerActions([
Action::make('edit')
->label('Edit profile')
->url(route('users.edit', $user)),
])
->footerActions([
Action::make('resend')
->label('Resend invitation')
->url(route('users.invitation.resend', $user))
->method('post')
->requiresConfirmation(),
])
->schema([...]);Keep the action endpoint protected by a policy and CSRF middleware. A hidden
button is not authorization. Header/footer schema slots can also contain
shared Text, EmptyState, or custom schema components when the UI needs
context around the entries.
PHP-first custom views
Use View when a reusable package needs a named renderer while keeping data
evaluation in PHP:
use Inlay\\Schemas\\Components\\Text;
use Inlay\\Schemas\\Components\\View;
use Inlay\\Schemas\\SchemaContext;
View::make('acme/order-summary')
->viewData(fn (SchemaContext $context): array => [
'number' => $context->get('order.number'),
'total' => $context->get('order.total'),
])
->schema([
Text::make('Payment captured')->color('success'),
]);Register acme/order-summary in the React or Vue schema registry. Use
defer() or lazy() for expensive, below-the-fold data; the endpoint must be
explicitly authorized and return the package's deferred-view contract. Never
turn a view name into a dynamic module path supplied by a user.
Themes and CSS hooks
Infolists inherit the Panel theme. A standalone instance can receive a small semantic override:
<Infolist
resource={details}
theme={{
accent: '#6d28d9',
controlBorder: '#cbd5e1',
surfaceMuted: '#f8fafc',
}}
className="profile-details"
/>Prefer theme tokens over hard-coded color classes. Useful shared hooks include
data-slot="root", schema, entry, label, value, helper-text,
empty-value, repeatable, repeatable-item, header-actions, and
footer-actions. The same hooks exist in React and Vue, allowing one theme
stylesheet to update all applications.
Testing
Test the PHP contract and the browser adapter separately:
it('serializes a user detail contract', function () {
$user = User::factory()->create(['name' => 'Ada']);
$resource = UserDetails::make($user)->jsonSerialize();
expect($resource['contract'])->toBe('inlay.infolists.v1')
->and($resource['data']['name'])->toBe('Ada');
});Use SchemaTester for component lookup, traversal, state, and closure
evaluation. In the adapter package, test the serialized resource with Vitest
and Testing Library: confirm labels, empty placeholders, keyboard tabs, copy
feedback, safe URL filtering, and custom renderer registration. The renderer
should not be expected to enforce Laravel policies; those belong in a PHP
feature test.
Security checklist
- Authorize the record before creating the Infolist.
- Pass only selected attributes into
data(). - Use server callbacks for permissions, tenant scope, and sensitive state.
- Use
url()/safe URL APIs instead of interpolating untrusted links. - Let PHP sanitize HTML/Markdown; do not pass arbitrary
dangerouslySetInnerHTML. - Keep relationship names and aggregate columns in application-owned code.
- Protect every action endpoint with middleware and policies.
- Test that hidden values are absent from the response when they are truly secret.