ajthinking/archetype
Archetype is a Laravel code generation package that lets you define models, migrations, controllers and more through a fluent PHP API, then scaffolds the files instantly—speeding up new features and project setup.
Install the 2.x line explicitly, because the 1.x line pins nikic/php-parser ^4.11 and will refuse to resolve next to Pest 3, Laravel 13-era tooling, or anything else on php-parser 5:
composer require --dev ajthinking/archetype:^2.0
It is a build-time/dev dependency. It parses and rewrites source files; it has no business being loaded on a request path.
Where to look first. Ignore the README's tour and go straight to src/Files/PHPFile.php and src/Ast/ASTQueryBuilder.php. The two entry points you will actually live in are:
PHPFile::make() (file creation / loading, then an endpoint call and ->save())->astQuery() (traversal and mutation of already-existing code)If you are in a Laravel app you get LaravelFile, which adds models(), controllers(), and serviceProviders() on top, driven by the package config.
First use case. Create a class if it does not exist, then preview before writing:
use Archetype\Facades\PHPFile;
PHPFile::make()
->class(\App\Services\InvoiceCalculator::class)
->preview(); // prints the generated source, writes nothing
Once that looks right, swap preview() for save(). The second use case — and the one that justifies the dependency — is editing an existing file in place:
PHPFile::load(\App\Models\Order::class)
->astQuery()
->class()
->classMethod('total')
->insertStmt(function (\Archetype\BuilderFactory $builder) {
return $builder->property('computedAt')->getNode();
})
->commit()
->end()
->save();
The astQuery() … commit() … end() shape is the core loop; learn it before anything else.
The query → mutate → commit → end → save pipeline. This is the only workflow that matters. Every astQuery() chain terminates in commit() (persist your mutation into the file object) and end() (drop back to the PHPFile so you can chain another query or call save()/preview()). Forgetting end() is the single most common source of "why is my second query doing nothing".
Query by path, not by callback. The builder flattens node class + property traversal into one dotted string, and properties are accessed without parentheses:
$file->astQuery()->classMethod('total->stmts') // class method named total, then its stmts
Callbacks are the escape hatch, not the default. Reach for ->where (the higher-order proxy) when you need to compare values:
->astQuery()
->class()
->property()
->where->propertyProperty('name->name')->is('computedAt')->get()
Use insertStmt with a closure when you need the builder. Passing a BuilderFactory closure lets you reuse the same fluent node constructors that PHPFile::make() uses, so generated nodes are structurally identical to hand-written ones:
->insertStmt(fn ($builder) => $builder->method('recalculate')->getNode())
Wrap everything in an Artisan command. The natural integration is a php artisan app:generate-* command rather than a Tinker session, because AST edits should be reproducible, reviewable, and re-runnable:
class SyncRepositories extends Command
{
protected $signature = 'app:sync-repositories {--write : Persist changes}';
public function handle()
{
$files = LaravelFile::models();
foreach ($files as $file) {
$file->astQuery()
->class()
->classMethod('booted')
->insertStmt(fn ($b) => $b->method('registerObservers')->getNode())
->commit()->end();
$this->option('write') ? $file->save() : $this->line($file->preview());
}
}
}
The --write flag pattern is not optional in practice. Treat preview() as your dry-run and ship it as the default.
Idempotency belongs to you, not the package. Archetype has no "only if not already present" semantics on insertStmt — query first, and guard:
$class = $file->astQuery()->class();
if (! $class->classMethod('registerObservers')->get()) {
$class->insertStmt(...)->commit()->end();
}
Use fromString with addMissingTags() for snippet-level work. Testing a generator, prototyping a transform, or building nodes from a template fragment does not require a real file:
PHPFile::addMissingTags()->fromString('$x = 1337;');
Custom makers are the sanctioned extension point. Since 1.1.0, PHPFile::make() returns a Maker, and you can attach your own maker to the file instance to produce house-style boilerplate (strict types, final classes, your team's docblocks). This is a better bet than post-processing with string replacement.
Test your generators with the package's own test facades. TestablePHPFile and TestableAstQueryBuilder exist precisely so you can assert on AST shape without touching disk. If you are writing a scaffolder, mirror the package's Pest suite — assert on the rendered string output, not on node internals, so a php-parser bump does not break your tests.
Pin ^2.0.1, not ^2.0.0. ->throw() silently matched nothing across the entire 2.0.0 release because the class map still pointed at the removed Stmt\Throw_. 2.0.0 also shipped commands_root and controllers_root in the config pointing at the non-existent app/HTTP/Controllers. Both are fixed in 2.0.1 — but only if you take 2.0.1.
Composer refusing to resolve is a version-line problem, not a broken package. If ajthinking/archetype will not install, you are on 1.x (directly or transitively, e.g. Statamic 4/3.4 pin ^1.0.3). The package cannot be installed alongside anything needing php-parser 5. That is the whole 1.x story; upgrade rather than fight it.
Do not plan around PHP 9. The upper bound on php is deliberate. Node identity relies on dynamic properties, which PHP 9 removes. This is a package that will need an internal rework for PHP 9 — factor that into any decision to build a critical scaffolder on top of it.
insertStmt on zero matches used to append to end-of-file. Fixed in 1.1.3 with a guard clause, but the lesson stands: always assert your query matched before committing. $query->get() returning empty is falsy, and some builder paths auto-resolve where-clauses against truthiness. Be explicit about whether you intended "match nothing" or "found nothing".
Format-preserving printing is good but not perfect. The maintainers themselves call out lingering formatting bugs in the 1.0.0 notes. Run on a clean working tree, git diff every generated change, and never run a bulk refactor with uncommitted work in the tree. preview() is cheap; use it.
getReflection() failing is now silent. Since 2.0.1 it catches Throwable instead of Exception, so LaravelFile::models()/controllers()/serviceProviders() will skip a file whose parent class or trait cannot be autoloaded rather than exploding. That is the right behavior for a bulk query, but it means a class silently disappearing from your generator's input set is a normal failure mode — count your inputs.
Avoid Str macros inside your generator code. 1.0.3 removed the package's own reliance on them after they caused segmentation faults in statamic/cms. If your scaffolder leans heavily on macro-laden string helpers, you are reintroducing a known crash class.
Debug with the built-in proxies. ->dd->something() and ->dump->another() are available directly on the query builder; there is also an opt-in visualizer (PR #45). Combined with preview(), that covers most day-to-day debugging without reaching for a real debugger.
Extension points, in order of safety: custom Makers (supported, documented by example) → closure-based node construction via BuilderFactory (supported) → adding node types to the class map (explicit since 1.1.2, no more magic __call) → writing your own Endpoint class (undocumented, follow the existing ones). The Endpoint layer is where the package's own churn lives, so treat anything below Maker as internal API.
Scope it as an enablement tool. This is the engine behind Laravel Blueprint and Statamic's internals — it is load-bearing infrastructure for a specific class of problem (scripted, repeatable edits to PHP source). It is not a scaffolding framework and it will not replace artisan make:* for one-off generation. Adopt it when you need the same transformation applied across many files, or when you need to modify files that already exist.
How can I help you explore Laravel packages today?