Skip to content

Customizing the Pipeline

Every feature this package publishes — models, model metadata, enums, resources, routes, form requests, broadcast channels, and broadcast events — runs through the same Collector → Generator → Transformer → Writer → Template pipeline, though not every feature uses all five stages. Each stage is swappable independently, per feature, via the config file: extend the built-in class, override the matching config key, and the rest of the pipeline keeps working unmodified.

php
// config/ts-publish.php

'models' => [
    'transformer_class' => App\TypeScript\CustomModelTransformer::class,
],

What Each Stage Does

  • Collector — discovers the fully-qualified class names to publish (e.g. every model in app/Models), applying the feature's included / excluded / additional_directories config.
  • Generator — orchestrates a single class's publish: builds a Transformer, hands it to a Writer, and holds the resulting file content. Also the integration point for the generation cache.
  • Transformer — converts one PHP class into the structured data (a Datable DTO) that describes what should be in the TypeScript output — no string building, just data.
  • Writer — renders a Transformer's data through a Template (a Blade view) and writes the resulting file to disk.
  • Template — the Blade view responsible for the actual TypeScript syntax. Publishable and editable independently of every other stage.

Pipeline Stages Per Feature

Not every feature has all four swappable classes — broadcast channels, for example, has no per-class Generator or Transformer stage, since a channel is just a name string, not a PHP class to statically analyze. Each stage is swapped via a {feature}.{stage}_class config key (e.g. models.collector_class) — the table below shows the resulting default class for each stage.

FeatureCollectorGeneratorTransformerWriter
ModelsModelsCollectorModelGeneratorModelTransformerModelWriter
Model MetadataModelMetadataCollectorModelMetadataGeneratorModelMetadataTransformerModelMetadataWriter
EnumsEnumsCollectorEnumGeneratorEnumTransformerEnumWriter
ResourcesResourcesCollectorResourceGeneratorResourceTransformerResourceWriter
RoutesRoutesCollectorRouteGeneratorRouteTransformerRouteWriter
Form RequestsFormRequestsCollectorFormRequestGeneratorFormRequestTransformerFormRequestWriter
Broadcast ChannelsBroadcastChannelsCollector(none)(none)BroadcastChannelsWriter
Broadcast EventsBroadcastEventsCollectorBroadcastEventGeneratorBroadcastEventTransformerBroadcastEventWriter¹

1 Broadcast Events also has two additional writer stages beyond the table above: index_writer_class (writes the combined index file) and echo_augmentation.writer_class (writes the Echo module augmentation).

Model Metadata adds two more extension points: model_metadata.provider_class, the class whose provide($model) supplies each companion's values (the one most apps customize), and Analyzers\Metadata\ModelMetadataAnalyzer, the analyzer consumer that resolves each key's TypeScript type from body inference, the @return docblock, and #[TsCasts] — a custom transformer_class calls it through the container. See Model Metadata.

BroadcastEventTransformer changed shape

Its constructor used to take a second argument alongside $findable — an Analyzer instance from Surveyor, the library that typed broadcast events at the time. Events are now typed by the package's own analyzer, and the constructor matches every other transformer:

php
public function __construct(string $findable);

The protected methods a subclass hooks into moved with it, so re-check any existing override:

  • convertType() and resolveArrayType() are gone, along with the $analyzed property, because all three took Surveyor types. This is the one that bites quietly: an override of a method the parent no longer calls is dead code, not an error, so a subclass that mapped a custom value object through convertType() keeps loading while its event types change underneath it.
  • runAnalysis(), resolveBroadcastName(), resolveProperties(), convertClassType() and collectPropertyFqcns() take or return different types. These fail loudly — PHP rejects the incompatible declaration when the subclass loads — so you'll know immediately.

ResourceTransformer lost its model-resolution methods

The four methods that decided which Eloquent model backs a resource have moved off the transformer into AbeTwoThree\LaravelTsPublish\Ast\ModelClassResolver, so the analyzer and the publish pipeline resolve a resource's model the same way. resources.transformer_class is still a supported override point; only these four names left it.

Fails quietly — this is the whole of it, so check by hand:

  • modelFromDocblock(), modelFromAncestorDocblock(), guessModelFromConvention() and guessModelFromUseResourceAttribute() are gone. They were protected on ResourceTransformer; they are private on ModelClassResolver, which is final. A subclass that overrode any of them still compiles and still loads — the parent simply never calls it again. So a convention override that resolved, say, App\Http\Resources\PostResource to App\Domain\Post stops applying, every affected resource is silently typed against a different model, and nothing errors.

Nothing on ResourceTransformer changed signature, so unlike the transformer above there is no loud half to warn you.

Migrating an override. Two paths, in order of preference:

  1. Override resolveModelClass(), still protected on ResourceTransformer and the single seam all four methods now sit behind. Set $this->modelClass and return $this:
php
protected function resolveModelClass(): self
{
    parent::resolveModelClass();

    $this->modelClass ??= MyConvention::modelFor($this->reflectionResource);

    return $this;
}
  1. Bind a replacement for ModelClassResolver — an escape hatch, not a supported override point the way resources.transformer_class is. The class is tagged @internal: everything under Ast other than AstEngine::analyze() and the AnalysisResult it returns changes without notice, so this name and signature can move under you. The mechanics do work — the pipeline resolves it from the container on every transform, so $this->app->bind(ModelClassResolver::class, MyResolver::class) in a service provider takes effect — but note it is auto-wired rather than registered, so there is no existing binding to decorate, and because the class is final a replacement cannot extend it. It must supply its own resolve(ReflectionClass $resource): ?string. Reach for it only when overriding resolveModelClass() genuinely cannot express your convention.

Each feature also has its own *.template config key (models.template, enums.template, routes.template, form_requests.template, broadcast_channels.template, and broadcast_events.template / index_template / echo_augmentation.template) pointing at the Blade view responsible for that feature's output syntax — see Publishing & Editing Templates.

Shared & Combined Writers

A few writers aren't tied to a single feature — they combine already-transformed data from multiple features, or write a single combined file:

WriterConfig KeyResponsibility
BarrelWriterbarrel_writer_classWrites every namespace directory's barrel index.ts file — see Modular Publishing
GlobalsWriterglobals.writer_classWrites the global declaration file combining every model/enum interface
JsonWriterjson.writer_classWrites the combined JSON definitions file
WatcherJsonWriterwatcher.writer_classWrites the collected-file-paths JSON used by file watchers

Features Without a Swappable Pipeline

Inertia and Vite Env are not part of this swappable pipeline — they have their own dedicated analysis logic (reading the HandleInertiaRequests middleware, or parsing .env) and only expose filename/output-directory config, with no *_class override keys. See Inertia and Vite Env for their configuration options.

InertiaSharedDataAnalyzer changed shape

There is no config key for it, but the class is resolved from the container, so a subclass bound in a service provider is a real (if undocumented) override point. Shared data is now typed by the package's own analyzer instead of Surveyor/Ranger, and the class changed with it.

Fails quietly — check these by hand:

  • The constructor no longer takes a Laravel\Ranger\Collectors\InertiaSharedData. PHP ignores extra arguments passed to a class with no declared constructor, so new InertiaSharedDataAnalyzer($collector) keeps working and silently discards the collector.
  • analyze() returns null when no Inertia\Middleware subclass is discovered, not when a collector came back empty.
  • setAppPaths() keeps its signature but no longer forwards to a collector. It only records the paths discoverMiddlewareClass() scans, so an override that decorated the forwarding call now decorates nothing.
  • buildTypeStringWithOverrides() keeps its signature but not its argument shape. Both parameters are now array<string, array{type: string, optional: bool}>; the first used to hold Surveyor Type objects, and the second plain type strings.
  • The result array gained a required typeImports key, and a valueImports key alongside it. Anything constructing that array by hand — a test double, a subclass that builds its own result — must supply typeImports; omit it and the template throws when it renders (an undefined-variable ErrorException, or a count(): null given TypeError when valueImports is missing too), so that half you will see. valueImports is softer and therefore worse: the template defaults it to [], so a hand-built result that omits it still renders — just without the import { type AsEnum } from '@tolki/ts'; and import { Role } from './app/enums'; lines that an EnumResource shared prop's role: AsEnum<typeof Role> needs. The published inertia-config.d.ts then spells names it never imports: a TS2304 Cannot find name, or a silent any wherever skipLibCheck hides it.

Fails loudly at class load:

  • buildResult() is now buildResult(string $middlewareClass) — the SharedDataComponent argument is gone.

New protected members a subclass can hook: resolveWithAllErrors(), collectProps(), rewriteEnumResourceTypes(), buildInferredImports(), keepSpelledNames(), forgetOverriddenChannels(), and the FRAMEWORK_OWNED_PROPS constant that keeps errors out of the inferred shape.

InertiaPageAnalyzer changed shape

Same situation as the shared-data analyzer above: no config key, but it is resolved from the container, so a subclass bound in a service provider is a real (if undocumented) override point. Per-route page props are now typed by the package's own analyzer instead of Surveyor/Ranger, and this class was rewritten around that.

Fails loudly: the constructor no longer takes a Laravel\Ranger\Collectors\Response. Its single parameter is an optional InertiaTableAnalyzer override, so new InertiaPageAnalyzer($collector) raises a TypeError the moment it runs. Construct it with no arguments.

Fails quietly — check these by hand:

  • The four type-string rewrite passes are gone: rewritePaginatorGenerics(), rewritePaginatedResourceProps(), rewritePaginatedStaticCollectionProps() and rewriteResourceCollections(), along with buildPageType() and resolveSingularResourceFqcn(). Paginators and resource collections are resolved from the props expression itself now, so an override of any of them is dead code rather than an error.
  • buildTypeStringWithOverrides() keeps its signature but not its argument shape. Its first parameter is now array<string, array{type: string, optional: bool}>, where it used to hold Surveyor Type objects.
  • buildPageData() takes different arguments: the per-component branch analyses, the analyzer they were produced by, and the #[TsCasts] overrides and import map — not a list of Ranger InertiaResponse objects and five prop-key maps.

Also removed: InertiaTableAnalyzer::isTainted() and resolveComponent(), and the whole table-taint family behind them. A controller that renders an Inertia UI Table no longer loses page types on its sibling actions — see Sibling Actions on a Table Controller.

New protected members a subclass can hook: analyzeAction(), analyzerFor(), collectComponentBranches(), analyzeProps(), propsArrayLiterals(), analyzeDelegatedProps(), collectProps(), usedFqcns() and forgetOverriddenChannels().

Two classes were removed outright

Neither had a config key, but both were public API in the loosest sense — importable, and referenced by at least one real integration. Both fail loudly, immediately.

  • Analyzers\Inertia\ControllerPaginatorAnalyzer is deleted. It existed to recover paginator and resource-collection shapes that the old type-string rewrite passes could not, and it became callerless once page props moved onto the engine — paginators are resolved from the props expression itself now. Any use of it is a fatal Class "…\ControllerPaginatorAnalyzer" not found.
  • Analyzers\SurveyorTypeMapper is deleted, and its TOLKI_TYPES_MAP constant is renamed. The map of PHP classes that @tolki/types declares TypeScript types for now lives at Support\TolkiTypes::MAP, on a class that does nothing else. Replace SurveyorTypeMapper::TOLKI_TYPES_MAP with TolkiTypes::MAP; the contents are unchanged. The rest of that class went with Surveyor.

Abstract Base Classes

Every built-in class extends one of these four abstract base classes. A custom class must extend the matching one and implement its abstract methods.

CoreCollector<TFindable>

php
abstract protected function defaultDirectory(): string;
abstract protected function classFilter(ReflectionClass $reflection): bool;

/** @return array{included: list<string>, excluded: list<string>, additional_directories: list<string>} */
abstract protected function finderSettings(): array;

/** @return Collection<int, class-string<TFindable>> */
public function collect(): Collection; // concrete — orchestrates the above

collect() itself is concrete and already handles merging additional_directories, included, and the default directory, filtering by classFilter(), and excluding anything matched by excluded or marked #[TsExclude]. A custom collector typically only needs to implement the three abstract methods.

Class maps are memoized per process

CoreCollector scans each directory once per PHP process and reuses that class map for the rest of it. ts:publish is unaffected — Runner::run() and RunnerForSource::run() both flush the memo first, so every run reads the disk, and nothing in the package writes a .php file mid-run.

It matters for host code that calls collect() or allows() on both sides of writing a PHP file — a custom collector, or a tinker session or test helper that generates a model and re-collects. The second call still returns the pre-write answer. Flush the memo between the write and the second call:

php
use AbeTwoThree\LaravelTsPublish\Collectors\CoreCollector;

CoreCollector::flushClassMapCache();

It is a static method on the base class, so one call clears the maps for every collector.

CoreGenerator<TGeneratable>

php
public function __construct(
    public protected(set) string $findable, // class-string<TGeneratable> — auto-calls generate()
) {}

abstract public function generate(): string;
abstract public function filename(): string;

The constructor calls generate() immediately, so by the time a Generator instance exists, $this->content should already hold the rendered output (typically by building a Transformer internally and delegating to a Writer).

CoreTransformer<TTransformable>

php
public function __construct(
    protected string $findable, // class-string<TTransformable> — auto-calls transform()
) {}

public function fqcn(): string; // concrete

abstract public function transform(): self;
abstract public function filename(): string;
abstract public function data(): Datable;

data() returns a Datable DTO — plain structured data describing the output, not a rendered string. This is what gets handed to a Writer (and what gets cached — see below).

CoreWriter<TTransformer of CoreTransformer>

php
public function __construct(
    protected Filesystem $filesystem, // constructor-injected
) {}

abstract public function write(CoreTransformer $transformer): string;

A Writer takes a Transformer instance and returns the rendered file content as a string (and, when output_to_files is enabled, is also responsible for actually writing it to disk).

Cache-Compatible Generators (RehydratesFromCache)

The built-in generators (ModelGenerator, ModelMetadataGenerator, EnumGenerator, ResourceGenerator, RouteGenerator, FormRequestGenerator, BroadcastEventGenerator) all use the AbeTwoThree\LaravelTsPublish\Generators\Concerns\RehydratesFromCache trait to participate in the generation cache. It adds:

php
public static function fromCache(string $findable, CoreTransformer $transformer, string $filename): static;

protected function hydrate(string $findable, CoreTransformer $transformer, string $filename): void;

fromCache() builds a generator instance via ReflectionClass::newInstanceWithoutConstructor() — skipping the normal constructor entirely, so generate() (and therefore the underlying transform() and file write) never runs again for a class the cache already has a valid, unchanged snapshot for. hydrate() then restores just enough state ($findable, the cached $transformer, and the cached $filename) for the rest of the pipeline (barrel writers, preview output, etc.) to treat it identically to a freshly generated instance.

Add this trait to a custom *.generator_class to opt it into the same behavior. A generator without it is always rebuilt from scratch on every run — correct, just not cached.

Example: Swapping a Transformer

php
namespace App\TypeScript;

use AbeTwoThree\LaravelTsPublish\Dtos\Contracts\Datable;
use AbeTwoThree\LaravelTsPublish\Transformers\ModelTransformer;

class CustomModelTransformer extends ModelTransformer
{
    public function transform(): self
    {
        parent::transform();

        // Add or adjust data before it reaches the Writer.

        return $this;
    }
}
php
// config/ts-publish.php

'models' => [
    'transformer_class' => App\TypeScript\CustomModelTransformer::class,
],

The same pattern applies to a Collector, Generator, or Writer — extend the built-in class for the feature you want to customize, override just the behavior you need, and set the matching *_class config key.

Publishing & Editing Templates

If you only need to change the generated TypeScript's formatting — not the underlying pipeline logic — publish the Blade templates directly instead of writing PHP classes:

bash
php artisan vendor:publish --tag="laravel-ts-publish-views"

Then point the feature's *.template config key at your published (or entirely custom) Blade view.

Released under the MIT License.