Language 中文 English
ThinkPHP Can Be This Smooth Too — Recreating the Filament Experience with ThinkPHP + Vue
News 2026-07-27 · About 55 min read

ThinkPHP Can Be This Smooth Too — Recreating the Filament Experience with ThinkPHP + Vue

In the first two articles, we used Laravel + Filament to argue that "server-driven UI is more friendly to AI." We showed the formula, the scoreboard, the five pillars, and the dogfooding loop. But a practical problem stands in the way of declaring victory: a large number of domestic teams' primary framework is ThinkPHP, not Laravel. Many Chinese engineering organizations have a decade of ThinkPHP muscle memory, a thick layer of in-house ThinkPHP libraries, and hiring pipelines built around it. Does that mean the dividend of "not separating by default" is locked to a framework — that you must abandon your stack to get the AI-friendly architecture? This article gives a clear answer: the dividend belongs to the architecture pattern of "declarative, backend-driven UI," not to any specific framework. We recreate the Filament experience with ThinkPHP 8 + Vue 3, and honestly state: on "Agent friendliness," the domestic line does have a gap, but the gap is bridgeable, and the framework risk has been reduced in dimensionality. By the end you should see that the principle we have been building is portable, and that portability is itself the safety net.

Lead

In the first two articles, we used Laravel + Filament to argue that "server-driven UI is more friendly to AI." We showed the formula, the scoreboard, the five pillars, and the dogfooding loop. But a practical problem stands in the way of declaring victory: a large number of domestic teams' primary framework is ThinkPHP, not Laravel. Many Chinese engineering organizations have a decade of ThinkPHP muscle memory, a thick layer of in-house ThinkPHP libraries, and hiring pipelines built around it. Does that mean the dividend of "not separating by default" is locked to a framework — that you must abandon your stack to get the AI-friendly architecture?

This article gives a clear answer: the dividend belongs to the architecture pattern of "declarative, backend-driven UI," not to any specific framework. We recreate the Filament experience with ThinkPHP 8 + Vue 3, and honestly state: on "Agent friendliness," the domestic line does have a gap, but the gap is bridgeable, and the framework risk has been reduced in dimensionality. By the end you should see that the principle we have been building is portable, and that portability is itself the safety net.

I. Single Source of Truth: One Schema Covers Both Lines

The core of the Filament experience is "describing an admin module with one declaration." To recreate it, the first step is to define a single source of truth: a JSON Schema metadata contract.

It converges the entire structure of a module — fields, types, components, validation, relations, permission points, and menus. Instead of scattering this knowledge across a migration, a model, a controller, a Vue component, and a menu config, it lives in one document. The key is not "that it is JSON," but its role:

  • It is the object the config panel reads and writes (the visual editor reads and writes it directly, without a separate internal DTO, zero drift);
  • It is the input to the generator;
  • It is the input to re-generation (re-running does not lose structure);
  • It is documentation for developers to read, and also the carrier for AI to use as context.

More importantly: the international line (Laravel Stubs) and the domestic line (ThinkPHP Stubs) share the same Schema, avoiding the metadata forking into two sets. This means the structural definition describing the "orders module" is written only once, regardless of whether it ultimately lands on Laravel or ThinkPHP — the framework is the skin, the Schema is the skeleton. When you later decide the domestic line should also exist for a partner team, you do not re-derive the module; you re-compile the same Schema against a different stub set.

// raise.json (excerpt: one Schema drives both the international and domestic lines)
{
  "module": "posts",
  "model": "Post",
  "fields": [
    { "name": "title", "type": "string", "required": true, "inTable": true },
    { "name": "body",  "type": "richtext", "inForm": true },
    { "name": "published", "type": "boolean", "inTable": true }
  ],
  "permissions": ["posts.create", "posts.edit", "posts.publish", "posts.delete"]
}

And, the metadata is compiled into physical code at the generation phase (a PHP declaration class + a template-rendered Vue shell config); there is no "read a config table to render arbitrary UI" logic at runtime. This is the fundamental dividing line from a "low-code runtime": we generate code, not interpret configuration. The distinction matters enormously for AI friendliness. A runtime that walks a config table at request time is opaque to the Agent — it cannot read the running app's behavior from the source, because the behavior is emergent. Generated code, by contrast, is static, greppable, and debuggable. The Agent maintains files, not an interpreted graph.

II. Precompiled Assets: Recreating Filament's "Basically No Compile"

A big part of why Filament feels "smooth" comes from its "basically no compile": the frontend shell is precompiled and published with the Composer package, end users only write PHP declaration classes and run php artisan filament:assets to copy assets — the whole process has no Node / npm. The developer never touches a bundler; the admin just works.

We copy the same model; the key is that compilation happens once in the "generator repo," not every time on the "user side":

  1. Generator repo (dev time) uses Vite to compile the generic CrudTable / CrudForm + a self-built Inertia client + Element Plus into public/admin/app.js and app.css (with a version hash for cache busting). Vite exists only in the generator repo, not in the generated project.
  2. At project generation time, the above compiled artifacts are written directly into the user project's public/admin/.
  3. Adding a module, the user only writes a PHP Resource declaration class → props drive the precompiled shell → zero compile, zero Node, zero npm.
  4. The asset-publish command mirrors Filament: php think raise:publish-assets (syncs admin-dist/* inside the generator to public/admin/).
# User side: pull up assets, no Node throughout
php think raise:publish-assets
# Equivalent to the Laravel side:
# php artisan filament:assets

There is a second, quieter benefit to compiling once. Because the shell is a frozen artifact with a version hash, every environment — dev, staging, production — runs the same compiled bundle. The classic "works on my machine" class of frontend bugs, born from two developers on different Node versions building the same source into different output, simply cannot occur. The bundle is a product, not a process. For an Agent maintaining the app, that stability means the UI layer is effectively a constant, not a variable it must re-derive on every task.

This step erases "frontend build" entirely from the user's daily life — it is exactly the domestic-line counterpart of the "not separating" experience. Notice the inversion of the usual SPA workflow: in a typical Vue project, every developer machine carries Node, and every feature addition triggers a rebuild. Here, the build is a one-time event performed by the tool maintainer, and the end user receives a frozen, versioned bundle. The Agent that adds a module never invokes a bundler, never resolves an npm conflict, never waits on a cold Vite cache — it writes PHP and moves on. That is how you keep the "language count" denominator at 1 even inside a Vue-flavored shell.

III. Inertia Bridge + Page / Resource Declarative Model

The bridge layer is a thin Inertia-for-TP adapter: the backend controller render returns {component, props}, with no REST contract seam; the backend owns routing and data. The protocol is tiny; the key is "no second contract is introduced." There is no /api/... surface for the Agent to keep aligned with a client; the page request and its data are produced by the same server-side call.

// Backend controller: render returns {component, props}, no REST contract
$page = new PostResource();
return inertia()->renderPage(PostResource::class, [
    'rows' => Post::paginate(10),
]);
// Inside the adapter: reflection extends the base class -> derive the skeleton -> serialize declaration + runtime data
// -> returns { component: 'Crud/Index', props: { resource, rows, meta } }

The adapter reads the Resource declaration through reflection, derives which fixed skeleton to use, serializes both the static declaration and the runtime data, and returns a single {component, props} envelope. The Vue client simply renders the named component with the given props. No API layer sits between the two.

The declarative model is isomorphic to Filament: PHP declares only "content," the fixed frontend skeleton writes "structure" hard-coded.

A Resource declares five content slots — list / filters / toolbar / form / row-actions; the fixed frontend CrudPage skeleton assembles these blocks in a fixed order (PageHeader → FilterBar → Toolbar → CrudTable → Pagination → DialogForm). PHP only decides "what is in a block," and never describes "how blocks are laid out" — this both holds the position of "code generator, not low-code" and keeps the Agent's maintenance surface minimal.

// app/admin/resources/PostResource.php (declares "content", does not describe layout)
class PostResource extends Resource
{
    public function table(): array
    {
        return [
            Column::make('title')->searchable(),
            Column::make('author.name')->label('作者'),
            Column::make('published')->boolean(),
        ];
    }

    public function filters(): array
    {
        return [ Filter::make('author')->relationship() ];
    }

    public function toolbar(): array
    {
        return [ Action::make('export')->can('posts.export') ];
    }

    public function form(): array
    {
        return [
            Field::make('title')->required(),
            Field::make('body')->richEditor(),
        ];
    }

    public function rowActions(): array
    {
        return [ Action::make('edit'), Action::make('delete')->danger() ];
    }
}

ThinkPHP + Vue architecture diagram

"Content-driven, structure-fixed" — this is exactly the mapping of Filament's Resource + custom Page model onto ThinkPHP. Different syntax, isomorphic skeleton. The Agent that learns to write a Filament Resource can learn to write a ThinkPHP Resource in minutes, because the shape of the thinking is identical: declare the columns, declare the filters, declare the form, declare the actions. The framework changed; the cognitive pattern did not.

IV. Layered Override: An Outlet for Determinism

"Generic components save 80% of repetition" must not become "blocking the 20% of customization." A skeleton that cannot be escaped is a cage. So we define four tiers, with deterministic resolution (convention over configuration):

| Tier | Scenario | How | Generated / Hand-written | |---|---|---|---| | ① Generic default | 80% standard modules | CrudTable / CrudForm read the PHP declaration and auto-render | PHP only | | ② Light customization | custom column / action / pre-submit validation | generic components expose slot + hook; register a small component to occupy the slot | zero whole-page Vue; optional 1 small component | | ③ Heavy customization | complex interaction / dedicated flow | place modules/X/Index.vue real SFC to override the generic | hand-write real Vue | | ④ Dual-side override | business logic also needs to change | PHP-side subclass Controller / custom action override | hand-write PHP |

The resolution order is determined in one line of code: modules/{Module}/Index.vue exists ? use it : use the generic CrudTable. This turns "customization" from "breaking the structure" into "replacing at a deterministic outlet" — the Agent knows where to change, and the human knows where to look.

Why this matters for AI: a generator that only supports tier ① produces uniform, limited apps; a generator that requires tier ③ for everything pushes the Agent back into hand-writing Vue and re-inflates the language count. The four-tier design lets the Agent stay in PHP for the common cases and drop to Vue only where the structure genuinely demands it. The override point is predictable — a known file path — so even when customization is required, the Agent is not improvising a new architecture; it is filling a slot the skeleton already named.

V. Honest Comparison: The Gap in Agent Friendliness and How to Bridge It

We must be honest: the domestic line (ThinkPHP + Vue separated output) does have lower "Agent friendliness" than the international line (Laravel + Filament + Boost). The core gap is the lack of a Boost-type MCP / Skills tooling ecosystem — without the 15+ tools provided by php artisan boost:mcp, the downstream Agent pays a higher cost to "see the project" when taking over. It cannot ask a tool "list all Resources and their permission points"; it must read files and infer. That inference is exactly the kind of uncertain work the formula penalizes.

The bridging path does not overturn the dual-track system, but fills in the context layer:

  • Add .ai/guidelines: solidify declaration conventions, data scope, and permission-point naming {module}.{action}, letting the Agent understand the project at zero cost;
  • Add an optional "server component template": precipitate common customizations into reusable, generatable templates, lowering the ratio of hand-written SFCs;
  • Do not overturn the dual-track: the two lines share the same JSON Schema; the difference is only "which set of Stubs to compile to," and the toolchains evolve independently without affecting each other.

A concrete before-and-after makes the gap tangible. Without these fills, an Agent handed the domestic project must read every Resource file to learn that permissions follow {module}.{action}, reconstruct the tenant scoping rule from middleware, and guess at the Inertia contract shape — minutes of uncertain inference per task, with drift risk on every guess. With .ai/guidelines present, the same Agent reads one short file and proceeds with certainty. We have not changed the framework; we have changed the information available at the start of the task, which is exactly the lever the Part One formula tells us to pull.

An even more critical layer of insight is: the framework risk has been reduced in dimensionality. Because the real asset is the "declarative Page system + precompiled distribution + plugin architecture," not any specific backend framework — what is bound to ThinkPHP today, if you want to shift to Python + Vue tomorrow, has only two binding points: the "user declaration class" and the "Inertia adapter"; 90% of the assembly logic can be ported directly. Switching frameworks is reduced from "rewrite the entire application" to "swap a set of generation templates."

This is the part worth sitting with. The thing you actually own is not ThinkPHP, and not Laravel — it is the description of your modules and the machine that turns that description into an app. The framework is a compilation target, swappable like a backend for a compiler. If a better server-side option appears, you do not migrate your application; you extend your generator. The 90% that is framework-agnostic — the schema, the declarative model, the precompiled shell, the layering rules — travels with you.

Golden line: Frameworks go out of fashion, but the "declarative page-assembly model" does not; that is what you should truly bet on.

Conclusion: Back to First Principles

The three articles arrive here, and we can draw a close.

In Part One, we questioned "separate by default" and gave the formula Agent output quality ≈ 1 / (number of languages × number of seams × structural uncertainty); in Part Two, we used Laravel + Filament to prove that "single-language + server-driven + built-in context" can drive that denominator extremely low; in Part Three, we used ThinkPHP + Vue to prove this route is not bound to a language — its dividend belongs to the architecture pattern, not to any framework.

The three threads converge into one sentence: in the Vibe Coding era, swap the muscle memory of "separate by default" for "single-language + server-driven UI by default" — this is not nostalgia, but a first principle. And when you actually get to work, whether you choose Laravel or ThinkPHP, remember to pay in advance for "AI taking over maintenance" — solidify conventions, wire up tools, write good guidelines, so that every generation and every handover stands on the shoulders of determinism.

The series set out to challenge an unexamined default. It ends not with a mandate but with a reframing: separation was the right answer to a human-coordination problem; integration is the right answer to a generation-uncertainty problem. Pick the principle that matches the problem in front of you — and now you have the scoreboard, the skeleton, and the portable pattern to do it well.