↓ Skip to main content

AI can write your Laravel app. Someone still has to design it

·12 mins
Hennadii Alforov
Author
Hennadii Alforov
Senior Backend Engineer with 15+ years in IT. I build and scale backend systems for high-traffic platforms - billing, payments, hosting and domain services.

An AI agent like Claude Code can write a Laravel or Symfony app today. Once the project is bigger than a book tracker for a school library, a developer still has to pick the architecture, stop the agent from rebuilding libraries that already exist, keep it away from staging and production, and hold the whole project in their head.

AI writes code fast, and it starts wherever you let it. Ask for features without a structure and it starts with controllers. A month later there are a hundred of them in one folder, each holding its own piece of business logic. Nothing is broken. You just can't change a rule in one place anymore, and neither can the agent.

Memory files, rules and hooks help, and I use a lot of them. They don't replace the person who decides what goes into them, and they're never finished. Here's where that showed up in my own work: a multi-tenant SaaS I build alone, and this site.

Pick the architecture before the first prompt
#

An agent follows the structure it sees. If there's none, it makes one up, and every session makes up a slightly different one.

For my SaaS I wrote the decision down early: a light version of DDD. Plain MVC was out, because business rules and multi-tenancy would end up in fat models and in logic copied between controllers and UI components. Full DDD was out too. I'm one developer, and repositories, aggregates and bounded contexts would cost me more than they'd give back. So I took the parts that pay off for one person:

  • one Action class per use case, with a single execute() method
  • DTOs on spatie/laravel-data for input and its validation
  • domain events for side effects, such as the audit log and notifications
  • thin Eloquent models, used straight from Actions, with no repositories

Each domain under app/Domain/ is built around an Actions/ folder, and the rest come from the same short list:

app/Domain/<Name>/
├── Actions/
├── DTOs/
├── Events/
├── Exceptions/
├── Listeners/
└── Policies/

The decision lives in a guide in the repository. CLAUDE.md sums it up and links to it, so every session starts from the same decision. When the agent adds a feature, it doesn't weigh a controller against a service or a job. It writes an Action, because every domain around it is built that way.

Make the agent use a library instead of its own code
#

In August I built a CSV and XLSX import the way I build most features now: a brainstorm with the agent, a written plan, then code. It worked. Upload a file, map the columns, confirm, and the records are there.

Then I read the code. Two things stood out.

The bigger one was a state machine for the import status, written by hand. A status column, checks before every change, its own exceptions. There are good packages for exactly this, so in review I had the agent replace it with spatie/laravel-model-states. It took half a day. Simplified, the whole graph now sits in one method:

abstract class ImportState extends State
{
    public static function config(): StateConfig
    {
        return parent::config()
            ->default(Pending::class)
            ->allowTransition(Pending::class, Processing::class)
            ->allowTransition(Pending::class, Cancelled::class)
            ->allowTransition(Processing::class, Completed::class)
            ->allowTransition(Processing::class, Failed::class);
    }
}

The other find was smaller: a hard limit on rows per file, with its own exception. The agent had taken the number from the biggest pricing plan. Raise that plan's limit one day and the import breaks, and nobody remembers where the number came from. Now the limit sits in config, with a comment that calls it a resource guard, not a business limit, and says what it's sized for.

None of this failed. The tests were green and the import worked, which is exactly why it's expensive: code that doesn't fail is code nobody reads again. Writing its own version of a solved problem costs an agent nothing. Reading that version a year later costs you. It's the same reflex I check for in take-home tasks: AI goes with what it already knows, down to library versions from its training data.

Limit what the agent can reach
#

In July an agent ran a seeder against a staging database. I thought it was working locally. The command was harmless. It pointed at the wrong database.

The lesson: a rule that must hold doesn't belong in CLAUDE.md. CLAUDE.md shapes what the model tries to do, but it doesn't change what Claude Code allows, and a model can misread it. Such rules go into permissions in .claude/settings.json and into hooks, which Claude Code enforces itself.

Deny rules and PreToolUse hooks in Claude Code
#

A deny rule beats any allow rule, a hook can't lift it, and per the docs it holds in every mode, bypassPermissions included. A starting point for .env files, secrets and keys, plus a hook on every shell command:

{
  "permissions": {
    "deny": [
      "Read(.env*)",
      "Read(!.env.example)",
      "Read(./secrets/**)",
      "Read(./**/*.pem)",
      "Read(./**/id_rsa)"
    ]
  },
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          {
            "type": "command",
            "command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/guard-destructive.sh"
          }
        ]
      }
    ]
  }
}

A bare .env* matches at any depth, so .env.staging is covered too, and the ! line opens the template back up.

The hook blocks commands that wipe data. Exit code 2 stops the call before permission rules are even checked, so an allow rule can't wave it through:

#!/usr/bin/env bash
# .claude/hooks/guard-destructive.sh: exit 2 blocks the call, even past an allow rule
command -v jq >/dev/null || exit 2
CMD=$(jq -r '.tool_input.command // ""')
PATTERN='migrate:(fresh|refresh|reset|rollback)|db:wipe|(compose|docker-compose).*down.*(-v|--volume)|DROP +(TABLE|DATABASE|SCHEMA)'

if printf '%s' "$CMD" | grep -qiE "$PATTERN"; then
  echo "Blocked: this command wipes data. Run it yourself if you mean it." >&2
  exit 2
fi
exit 0

The jq check makes it fail closed: without jq the script would read an empty command and let everything through.

Why a block and not an ask: in August I found that with a broad allow rule like Bash(php *), a hook's ask on a PHP command never showed up, and only a deny fired every time. A Claude Code issue reports ask not being enforced too, and it was closed as not planned. If a command must not run without you, block it and run it yourself.

Feed this hook php artisan db:seed and it stays silent: db:seed isn't in the pattern, so it wouldn't have stopped my seeder. Rules catch commands you can name. They don't see where a command points. Laravel has its own guard, DB::prohibitDestructiveCommands($this->app->isProduction()) in a service provider, which makes db:wipe, migrate:fresh and migrate:rollback refuse to run in production. It goes by the app's environment, so a local app pointed at a staging database still counts as local, and db:seed isn't on its list.

Deny rules have edges too. A Read rule blocks cat .env in the shell, but not a grep -r run from that folder or a script that opens the file itself. The built-in sandbox closes that at the OS level: its filesystem.denyRead applies to every shell command and the processes it starts. It's off by default, and anything you list in excludedCommands, like docker compose * in the docs' own example, runs outside it.

So the real fix is boring: keep staging and production credentials out of the shell the agent works in, and bring them in only for the task that needs them.

docker ps joins filters on the same key with OR
#

On 27 September I ran an SEO audit of gaalferov.com with subagents, in auto mode. The main session wrote their prompts, and one of them said to "clean up any docker containers you started". The subagent ran this:

docker ps -a --filter status=exited --filter status=running -q | xargs -r docker rm -f

docker ps joins two filters on the same key with OR, not AND. I checked it on Docker 29.8.0 with docker ps alone. So the list held every container on the machine, and all 7 of my local containers stopped, another project's development stack among them. Auto mode didn't help: its classifier sees the command, not what docker ps returned, so it had no way to spot the OR.

Since then every subagent prompt says what's allowed and what's forbidden before launch. A correction halfway through comes too late. From Docker a subagent gets docker run --rm and nothing else, and cleanup is the main session's job, by exact container name. That rule is still a line in CLAUDE.md, the weaker layer from above. A deny rule like Bash(docker rm *) wouldn't have caught this command either: Claude Code strips a bare xargs before matching, but not xargs -r. A hook that reads the whole command line would.

Keep the whole project in your head
#

The agent questions the plan first
#

In July I gave the agent a plan I'd thought through for a CLI command. It didn't start coding. It asked three questions, one at a time, and one of them broke the plan.

The command had to run across all tenants at once. Everything else in that project assumes the opposite: each operation runs inside one tenant, and the tenant scope fails closed, so a query without a tenant returns no rows. The agent asked how the command should behave with that, and I designed the way around it. Without the question, the plan would have pushed a cross-tenant job through a single-tenant scope. It would have passed review and broken on the first real run.

That step comes from the brainstorming skill in superpowers. I'd keep the habit without the skill too: the agent doesn't touch the code until it has questioned the plan. And the questions only help if someone knows which answer is right.

laravel-data doesn't validate from() arrays by default
#

spatie/laravel-data validates a DTO built with ::from() only when it gets a request. The default validation_strategy is OnlyRequests, so an array passes unchecked. I learned it in September from a rule that was in the code, passed review, had green tests and never ran. A value picked from a select was checked against an allowed list on the server, and the DTO was built from an array. A made-up DTO with the same shape:

final class UpdateLocaleData extends Data
{
    public function __construct(
        #[In(['en', 'pl', 'de'])]
        public string $locale,
    ) {}
}

// With the default config, this object is created with locale = 'xx'
UpdateLocaleData::from(['locale' => 'xx']);

With 'validation_strategy' => ValidationStrategy::Always->value in config/data.php, the same call throws a ValidationException. I ran both in a clean Laravel 13 container. The project has Always now, with a comment on why.

The tests were green for the wrong reasons. The DTO test called the static validate() helper, which production never calls. The form test expected a validation error and got one, but on a required field the factory left empty, so it never reached the rule either. A later review caught it.

What I took from it: a rule isn't checked until a test sends it a value it must reject.

Your AI setup is never finished
#

My own setup grew one piece at a time. None of it was there on day one:

  • a CLAUDE.md that I keep rewriting as the project changes
  • deny rules, then hooks: one flags a PHP file without declare(strict_types=1) or with an inline FQCN, one runs Pint on changed files when a session stops, one guards destructive commands
  • review agents that only report findings, each with one concern and a fresh context
  • validation_strategy => Always, after the rule that never ran
  • most recently, a generalist reviewer that reads the final diff whole, because each specialist looked only at its own slice and nobody saw the change in full

Around them sit architecture tests for the rules that must never break, such as every tenant-owned model using the tenant scope, plus static analysis and the test suite in CI.

The tool changes under you too. Since v2.1.283 Claude Code starts interactive sessions in auto mode on every plan, where a classifier approves commands instead of you. If your safety net was reading every command before pressing yes, an update can take it away. To keep approving them yourself, set permissions.defaultMode to "default" in ~/.claude/settings.json. Either way, I treat the setup like code. After every miss I ask which layer should have caught it, a rule, a hook, a test or a reviewer, and I add it there.

Where to start
#

If you're starting from zero, I'd go in this order:

  1. Write the architecture decision into the repository and point CLAUDE.md to it.
  2. Take staging and production credentials out of the shell the agent works in.
  3. Add Read deny rules for .env files, secrets and keys, and a PreToolUse hook that blocks commands that wipe data.
  4. If you use spatie/laravel-data, set validation_strategy to Always.

All of this catches what can be written down, and every piece of it is something I once decided was worth writing. What to write next, and which task the agent gets, is a person's call.

Writing that task is most of my job now: acceptance criteria the agent can't misread. For a monthly command that runs across all tenants, it looks like this:

Task: a monthly command that recalculates balances for every tenant.
Done when:
- it runs outside any tenant and handles each tenant in its own context
- a tenant that fails is logged and skipped, the rest still run
- a second run in the same month changes nothing
- a test with two tenants proves no row crosses between them

The agent writes the code. Deciding what "done" means is still on me, and so is fixing the setup the next time something gets past it.

FAQ
#

Can AI choose the architecture for a Laravel project?
It can propose one, and a good model gives you reasonable options. But it doesn't know your team size, your product or what you'll need in a year. Pick the architecture yourself, write it down in the repository, for example in docs/guides/, and point CLAUDE.md to it, so every session builds the same way.
Is CLAUDE.md enough to stop an AI agent from running dangerous commands?
No. CLAUDE.md shapes what the model tries to do, and the model can misread it. Deny rules in .claude/settings.json and PreToolUse hooks are checked by Claude Code itself. Keep staging and production credentials out of the local shell as well, because rules can't see where a command points.
Does a Read deny rule keep .env away from Claude Code?
Partly. A Read deny rule blocks Claude's file tools and shell commands that name the file, like cat .env, but not a grep -r run from that folder or a script that opens the file itself. To cover every process, turn on the sandbox and add the path to sandbox.filesystem.denyRead.
Should I set validation_strategy to Always in spatie/laravel-data?
If your DTOs are built with ::from() from arrays, yes, or their validation attributes never run. The default, OnlyRequests, validates only when a request is passed in. If you'd rather not validate every from() call, use validateAndCreate() where input comes in.