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.
// 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'sincluded/excluded/additional_directoriesconfig. - Generator — orchestrates a single class's publish: builds a
Transformer, hands it to aWriter, and holds the resulting file content. Also the integration point for the generation cache. - Transformer — converts one PHP class into the structured data (a
DatableDTO) 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.
| Feature | Collector | Generator | Transformer | Writer |
|---|---|---|---|---|
| Models | ModelsCollector | ModelGenerator | ModelTransformer | ModelWriter |
| Model Metadata | ModelMetadataCollector | ModelMetadataGenerator | ModelMetadataTransformer | ModelMetadataWriter |
| Enums | EnumsCollector | EnumGenerator | EnumTransformer | EnumWriter |
| Resources | ResourcesCollector | ResourceGenerator | ResourceTransformer | ResourceWriter |
| Routes | RoutesCollector | RouteGenerator | RouteTransformer | RouteWriter |
| Form Requests | FormRequestsCollector | FormRequestGenerator | FormRequestTransformer | FormRequestWriter |
| Broadcast Channels | BroadcastChannelsCollector | (none) | (none) | BroadcastChannelsWriter |
| Broadcast Events | BroadcastEventsCollector | BroadcastEventGenerator | BroadcastEventTransformer | BroadcastEventWriter¹ |
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:
public function __construct(string $findable);The protected methods a subclass hooks into moved with it, so re-check any existing override:
convertType()andresolveArrayType()are gone, along with the$analyzedproperty, 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 throughconvertType()keeps loading while its event types change underneath it.runAnalysis(),resolveBroadcastName(),resolveProperties(),convertClassType()andcollectPropertyFqcns()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()andguessModelFromUseResourceAttribute()are gone. They wereprotectedonResourceTransformer; they areprivateonModelClassResolver, which isfinal. 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\PostResourcetoApp\Domain\Poststops 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:
- Override
resolveModelClass(), stillprotectedonResourceTransformerand the single seam all four methods now sit behind. Set$this->modelClassand return$this:
protected function resolveModelClass(): self
{
parent::resolveModelClass();
$this->modelClass ??= MyConvention::modelFor($this->reflectionResource);
return $this;
}- Bind a replacement for
ModelClassResolver— an escape hatch, not a supported override point the wayresources.transformer_classis. The class is tagged@internal: everything underAstother thanAstEngine::analyze()and theAnalysisResultit 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 isfinala replacement cannot extend it. It must supply its ownresolve(ReflectionClass $resource): ?string. Reach for it only when overridingresolveModelClass()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:
| Writer | Config Key | Responsibility |
|---|---|---|
BarrelWriter | barrel_writer_class | Writes every namespace directory's barrel index.ts file — see Modular Publishing |
GlobalsWriter | globals.writer_class | Writes the global declaration file combining every model/enum interface |
JsonWriter | json.writer_class | Writes the combined JSON definitions file |
WatcherJsonWriter | watcher.writer_class | Writes 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, sonew InertiaSharedDataAnalyzer($collector)keeps working and silently discards the collector. analyze()returnsnullwhen noInertia\Middlewaresubclass is discovered, not when a collector came back empty.setAppPaths()keeps its signature but no longer forwards to a collector. It only records the pathsdiscoverMiddlewareClass()scans, so an override that decorated the forwarding call now decorates nothing.buildTypeStringWithOverrides()keeps its signature but not its argument shape. Both parameters are nowarray<string, array{type: string, optional: bool}>; the first used to hold SurveyorTypeobjects, and the second plain type strings.- The result array gained a required
typeImportskey, and avalueImportskey alongside it. Anything constructing that array by hand — a test double, a subclass that builds its own result — must supplytypeImports; omit it and the template throws when it renders (an undefined-variableErrorException, or acount(): null givenTypeErrorwhenvalueImportsis missing too), so that half you will see.valueImportsis softer and therefore worse: the template defaults it to[], so a hand-built result that omits it still renders — just without theimport { type AsEnum } from '@tolki/ts';andimport { Role } from './app/enums';lines that anEnumResourceshared prop'srole: AsEnum<typeof Role>needs. The publishedinertia-config.d.tsthen spells names it never imports: aTS2304 Cannot find name, or a silentanywhereverskipLibCheckhides it.
Fails loudly at class load:
buildResult()is nowbuildResult(string $middlewareClass)— theSharedDataComponentargument 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()andrewriteResourceCollections(), along withbuildPageType()andresolveSingularResourceFqcn(). 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 nowarray<string, array{type: string, optional: bool}>, where it used to hold SurveyorTypeobjects.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 RangerInertiaResponseobjects 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\ControllerPaginatorAnalyzeris 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. Anyuseof it is a fatalClass "…\ControllerPaginatorAnalyzer" not found.Analyzers\SurveyorTypeMapperis deleted, and itsTOLKI_TYPES_MAPconstant is renamed. The map of PHP classes that@tolki/typesdeclares TypeScript types for now lives atSupport\TolkiTypes::MAP, on a class that does nothing else. ReplaceSurveyorTypeMapper::TOLKI_TYPES_MAPwithTolkiTypes::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>
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 abovecollect() 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:
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>
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>
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>
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:
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
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;
}
}// 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:
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.