Custom Types
The Laravel TypeScript Publisher types each value from your PHP code. When a class should publish as a type your frontend already defines, such as a node from a UI package or a money value, put #[TsType] on the class. Every value of that class and of its subclasses then publishes your type, with its import, wherever the package finds the value:
- Inertia page props and shared data
- API resources
- Model attributes and casts
- Broadcast events
AstEngine::analyze()
Setting a Class's Type
#[TsType] lives in the AbeTwoThree\LaravelTsPublish\Attributes namespace and takes the type in one of two forms:
| Form | Example | Effect |
|---|---|---|
| A string | #[TsType('string')] | Publishes the type as written |
An array with import | #[TsType(['type' => 'UiNode', 'import' => '@js/types/ui'])] | Publishes the type and imports it from that path |
In this example, a UI package's base class sets its type, and Panel inherits it:
use AbeTwoThree\LaravelTsPublish\Attributes\TsType;
use Illuminate\Contracts\Support\Arrayable;
#[TsType(['type' => 'UiNode', 'import' => '@js/types/ui'])]
abstract class Node implements Arrayable
{
public function toArray(): array
{
return ['kind' => 'node'];
}
}
final class Panel extends Node {}Every Panel value then publishes UiNode and imports it, as the page props example shows. The type replaces whatever the package would infer for the class, from toArray(), jsonSerialize(), __toString() or its public properties. A #[TsCasts] entry for one key still wins over it. The type is also a promise about what Laravel sends for the class, and the package can't check that promise. See Keeping the Type True.
Where a Type Is Inherited From
A class takes the #[TsType] of each trait it uses, each interface it implements and each parent class, at any depth. When a class takes exactly one type, its values publish it as written:
| Class | Its own #[TsType] | Publishes |
|---|---|---|
Panel extends Node | None | UiNode, from Node |
Badge implements HasIcon | None | UiIcon, from HasIcon |
Pennies extends Amount | None | UiAmount, from Amount |
Composed Types
When a class takes two or more types, the package writes one interface that extends them all. Here Chart takes a type from its trait, its interface and its parent class:
#[TsType(['type' => 'UiHello', 'import' => '@js/types/ui'])]
trait AnnouncesHello
{
// ...
}
#[TsType(['type' => 'UiIcon', 'import' => '@js/types/ui'])]
interface HasIcon {}
final class Chart extends Node implements HasIcon
{
use AnnouncesHello;
}The package names the interface {Basename}Type, after the class. The interface gets a file of its own, which follows the class's namespace as a model's file does, so App\Packages\Ui\Chart writes app/packages/ui/chart.ts:
import type { UiHello, UiIcon, UiNode } from "@js/types/ui";
export interface ChartType extends UiHello, UiIcon, UiNode {}The directory's index.ts barrel exports it, and every file with a Chart value imports ChartType from that barrel. The global declaration file declares it too.
A class's own #[TsType] joins the ones it inherits:
#[TsType(['type' => 'UiDial', 'import' => '@js/types/ui'])]
final class Dial extends Node {}Dial publishes DialType, which extends UiDial and then UiNode.
The extends list starts with the nearest type: the class's own, then its traits', then its interfaces', and then its parent class's, which follows the same order.
Standing Alone With inherit: false
Pass inherit: false to publish a class's own type without the ones it would inherit:
#[TsType(
['type' => 'UiGauge', 'import' => '@js/types/ui'],
inherit: false,
)]
final class Gauge extends Node {}A Gauge value publishes UiGauge, and the package writes no GaugeType. On a parent class, a trait or an interface, inherit: false passes on only its own type, without the ones it inherits.
Types That Can't Be Extended
An interface can extend only a named type, such as UiNode, Acme.Ui.Node or Paginated<Chart>. A keyword type such as string, a union, an array, a literal and a Record<…> can't join a composed type. When one of a class's types can't, the nearest type wins, and ts:publish names the types it left out in a warning:
#[TsType('string')]
final class Spark extends Node {}A Spark value publishes string, and the run prints:
App\Packages\Ui\Spark: Publishes the #[TsType] [string] alone and leaves out [UiNode] from [App\Packages\Ui\Node]: a composed type cannot extend [string].Add inherit: false to the class's own #[TsType] to keep its type and drop the warning.
A Composed Type Can Fail tsc
The package reads only the name of each type, not what it holds. The composed interface fails tsc when a name aliases a type that an interface can't extend, such as Money in type Money = number. It also fails when two types declare one member with different types. Give that class its own #[TsType] with inherit: false.
Lists and Nullable Values
Lists, nullable values and optional keys keep their form:
- A list of the class publishes a list of the type. That covers an array literal,
collect([...]), alist<Chart>docblock and anAsCollection::of(Chart::class)cast, so[new Panel, new Panel]publishesUiNode[]. - A value that can be
nullkeeps its| null, as in?Dial, a nullable column, or a ternary with anullarm. A type that already includesnull, such as'UiNode | null', doesn't get a second one. - A value that is sometimes left out keeps its
?, as with$this->when()in a resource orInertia::defer()in a page.
Where the Type Applies
The examples below are from one app. Each output shows the keys that its PHP shows.
Inertia Page Props and Shared Data
A page prop publishes the type of each class value, in a list, behind a ternary, and in the value a deferred or optional prop's closure declares:
return Inertia::render('Ui/Dashboard', [
'widgets' => [new Panel, new Panel],
'maybe' => $request->boolean('maybe') ? new Panel : null,
'coll' => collect([new Panel, new Panel]),
'composed' => new Chart,
'gauge' => new Gauge,
'pennies' => new Pennies(5),
'deferred' => Inertia::defer(fn (): Gauge => new Gauge),
'optional' => Inertia::optional(fn (): Chart => new Chart),
]);The page-props type imports ChartType from Chart's module:
import type { UiAmount, UiGauge, UiNode } from "@js/types/ui";
import type { ChartType } from "../../packages/ui";
export type IndexPageProps = Inertia.SharedData & {
widgets: UiNode[];
maybe: UiNode | null;
coll: UiNode[];
composed: ChartType;
gauge: UiGauge;
pennies: UiAmount;
deferred?: UiGauge;
optional?: ChartType;
};The share() method of your HandleInertiaRequests middleware publishes these types the same way.
API Resources
A resource publishes the type for a value it builds with new, and for a helper method's declared return type:
class UiPanelResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'chart' => $this->chart(),
'dial' => $this->dial($request->boolean('dial')),
'panel' => new Panel,
'panels' => [new Panel, new Panel],
'when_panel' => $this->when($request->boolean('panel'), new Panel),
'maybe_chart' => $request->boolean('chart') ? new Chart : null,
];
}
protected function chart(): Chart
{
return new Chart;
}
protected function dial(bool $flag): ?Dial
{
return $flag ? new Dial : null;
}
}The interface imports each type it names:
import type { UiNode } from "@js/types/ui";
import type { ChartType, DialType } from "../../packages/ui";
export interface UiPanelResource {
chart: ChartType;
dial: DialType | null;
panel: UiNode;
panels: UiNode[];
when_panel?: UiNode;
maybe_chart: ChartType | null;
}Laravel writes these values with json_encode(), not toArray(), so Node must implement JsonSerializable, returning $this->toArray(), to send the same shape here. See Keeping the Type True.
Model Attributes and Casts
A model publishes the type for a cast that maps to the class, such as AsCollection::of(), and for an accessor that returns it:
class UiBoard extends Model
{
protected $appends = ['chart'];
protected function casts(): array
{
return [
'panels' => AsCollection::of(Panel::class),
'charts' => AsCollection::of(Chart::class),
];
}
protected function chart(): Attribute
{
return Attribute::make(get: fn (): Chart => new Chart);
}
}The two nullable JSON columns keep their | null:
import type { UiNode } from "@js/types/ui";
import type { ChartType } from "../packages/ui";
export interface UiBoard {
panels: UiNode[] | null;
charts: ChartType[] | null;
chart: ChartType;
}A custom cast class takes #[TsType] the same way, so every column that uses the cast publishes its type:
#[TsType(['type' => 'MenuSettingsType', 'import' => '@js/types/settings'])]
class MenuSettings implements CastsAttributes
{
// get() and set() ...
}
class Profile extends Model
{
protected $casts = [
'menu_settings' => MenuSettings::class,
];
}The Profile interface imports the cast's type:
import type { MenuSettingsType } from "@js/types/settings";
export interface Profile {
menu_settings: MenuSettingsType | null;
}Broadcast Events
An event publishes the type for each public property, and for each property of a value object it holds:
final class Board
{
public function __construct(
public Panel $panel = new Panel,
public Chart $chart = new Chart,
) {}
}
final class UiBoardUpdated implements ShouldBroadcast
{
public function __construct(
public Panel $panel,
public Pennies $pennies,
public Badge $badge,
public ?Panel $maybe = null,
public Chart $chart = new Chart,
/** @var list<Chart> */
public array $charts = [],
public Board $board = new Board,
) {}
public function broadcastOn(): Channel
{
return new Channel('ui-board');
}
}board keeps each member's type:
import type { UiAmount, UiIcon, UiNode } from "@js/types/ui";
import type { ChartType } from "../packages/ui";
export interface UiBoardUpdated {
panel: UiNode;
pennies: UiAmount;
badge: UiIcon;
maybe: UiNode | null;
chart: ChartType;
charts: ChartType[];
board: { panel: UiNode; chart: ChartType };
}Laravel writes the values inside charts and board with json_encode(). See Keeping the Type True.
Enums
An enum takes #[TsType] too:
#[TsType(['type' => 'UiToneDto', 'import' => '@js/types/ui'])]
enum UiTone: string
{
case Bright = 'bright';
case Dim = 'dim';
}A value that is the enum itself publishes your type: a case, a property or return type declared as the enum, and an attribute cast to it. The enum's own file, ->value, ->name and EnumResource keep the enum's names. In UiPanelResource, 'tone' => UiTone::Bright and 'tone_resource' => EnumResource::make(UiTone::Bright) publish:
import { type AsEnum } from "@tolki/ts";
import { UiTone } from "../../enums";
import type { UiToneDto } from "@js/types/ui";
export interface UiPanelResource {
tone: UiToneDto;
tone_resource: AsEnum<typeof UiTone>;
}Don't name the type after the enum's type alias (UiToneType). A case can then publish the enum's alias instead of yours.
Models, Resources, and Other Published Classes
A model, an API resource, a broadcast event, a form request, an enum and a controller with routes each publish their own file, so they never compose. Such a class takes its own #[TsType], or else its nearest parent class's. The package leaves out the #[TsType] of its traits and interfaces, and ts:publish warns. A #[TsType] on a model or an API resource doesn't apply to a new of that class.
Keeping the Type True
The package publishes your type as written, so make sure the class sends that shape. Laravel sends an Arrayable class in one of two ways:
- Laravel calls
toArray()on an Inertia prop and on each value inside an array prop, on a broadcast event's public property, and on a model attribute, including each item of a collection attribute. - Laravel writes every other value with
json_encode(): a value in an API resource'stoArray()array or an event'sbroadcastWith()array, a value inside an array an event property holds, and a value inside a value object.json_encode()ignorestoArray()and sends the class's public properties.
So an Arrayable class that isn't JsonSerializable can send two different shapes. Implement JsonSerializable and return $this->toArray() from jsonSerialize() to send one shape everywhere, or return an API resource for the value instead.
Configuration
The package writes a composed type's module whenever a published file uses a value of the class, so most apps need no config. The class_types section controls which classes compose:
// config/ts-publish.php
'class_types' => [
'enabled' => true,
'additional_directories' => ['app/Packages/Ui'],
'included' => [],
'excluded' => [],
],enabled: set it tofalseto stop composing. Each value then publishes its class's nearest type.additional_directories: the package writes a module for each composed class in these directories, even when no value uses it yet. It scans no directory by default. The Vite plugin watches these classes and the classes, traits and interfaces they take types from. It doesn't watch a composed class that only a published file uses.includedandexcluded: each list takes class names or directory paths. A class they leave out, or one with#[TsExclude], doesn't compose, and its values publish its nearest type with no warning.
php artisan ts:publish --only-class-types writes only the modules. --source with a composed class republishes its module. After you add a composed class, run a full ts:publish, because a run that skips class types can write a file that imports a module no run has written yet.
The Configuration Reference lists every class_types.* key.