← Back to list

A guide to strict AI engineering in the agentic era

A practical, harness-agnostic playbook for using stop hooks, pre-push hooks, and strict defaults to force every generated line through a…

JP Caparas in AI @ Sulat.com · 2026-07-13 05:52 · 21 claps · 13.6 min read
#software-development #vibe-coding #claude-code #chatgpt #programming
Open on Medium ↗
Wiki topics: LLM · Large Language Models AGT · AI Agents 💻 · Programming

A guide to strict AI engineering in the agentic era

A practical, harness-agnostic playbook for using stop hooks, pre-push hooks, and strict defaults to force every generated line through a real quality gate.

If you’re treating agent-written code as production code, this is the testing and governance primer you’re probably missing.

AI coding agents aren’t coming. They’re already here, and they’re writing migrations, models, controllers, form requests, and frontend components in one pass.

If your reaction is to treat that output as a draft, you’re almost halfway there. The other half is making sure every generated line gets kicked through the same quality gates you would have applied to your own code.

This is what coding in the agentic should look like.

This is what coding in the agentic should look like.

That’s the central argument of Nuno Maduro’s Laralive JP 2026 talk, “Strict Engineering”. The 51:00 mark is where he calls the current moment “strict AI engineering” and says it plainly: “Agent coding isn’t vibe coding.”

Every prompt is a decision you own.

The generated code is still yours.

This guide is a practical playbook for making that ownership real. It’s written for software engineers using any harness, whether that’s Codex, Claude Code, OpenCode, Devin, or your own custom setup. (Hint: it starts with *Pi*).

This guide draws heavily from Laravel and the PHP ecosystem because they have spent the last few years building strict, opinionated tooling that AI agents need to produce good code. The goal is simple: 100% code coverage (or as close as practically enforceable), a testing pipeline that runs before the agent can call a turn “done”, and a framework whose defaults refuse to let the machine hide mistakes.

[embed]Laravel is the framework for the agentic era Opinionated frameworks don’t just help developers. They help AI agents.ai.sulat.com

What makes framework choice worth your while

In the agentic era, the framework is the contract you give the agent, not only the library you use to build the app. A framework with strict defaults, consistent conventions, and strong static analysis gives the agent a shape to copy. A framework with loose defaults and silent failures gives the agent a pool of ambiguity to drown in.

Laravel is the clearest example. It has always shipped with good defaults even in the pre-AI era, but the recent wave of tooling turns those defaults into guardrails.

The nunomaduro/essentials package, described by Laravel News as "better defaults for your Laravel applications", enables strict models that throw on missing attributes and lazy loading. It uses CarbonImmutable by default, forces HTTPS, blocks destructive Artisan commands in production, and prevents stray HTTP requests in tests.

[embed]GitHub - nunomaduro/essentials: Just better defaults for your Laravel projects. Just better defaults for your Laravel projects. Contribute to nunomaduro/essentials development by creating an account…github.com

Laravel’s own AI SDK shows the same philosophy: first-party conventions for agents, tools, memory, and structured output that sit on the queues and filesystems Laravel developers already know.

[embed]

You don’t have to use Laravel, though. The same principle applies to any stack. The framework you pick has to have:

  • Strict defaults that fail loudly. Accessing a missing property, lazy-loading an N+1, or running a destructive command in production should throw, not silently return null or run without complaint.
  • A single command that runs the whole quality pipeline. composer test should mean lint, type coverage, static analysis, and tests. If you're running four different commands, the agent will skip one.
  • Conventions that the agent can read. Final classes, typed signatures, form requests, action patterns, and declare(strict_types=1) aren't just preferences. They're training signals the agent uses to infer the next file it should generate.

Strict frameworks fail loudly; loose frameworks let agent-generated mistakes slip through.

Strict frameworks fail loudly; loose frameworks let agent-generated mistakes slip through.

The Laralive JP talking points: Nuno in his own words

At the start of his Laralive JP talk, Nuno frames the talk as “strict AI engineering” and gives a set of tips “to make you generate better code using tools like AI”. The practical core breaks into three themes:

  • strict defaults,
  • strong typing, and
  • a testing pipeline the agent can’t bypass.

On defaults, he shows Model::shouldBeStrict() and Model::automaticallyEagerLoadRelationships() in the AppServiceProvider. The first prevents missing attributes and lazy loading; the second removes one of the most common performance killers in Laravel applications.

He also shows prohibitDestructiveCommands() blocking migrate:fresh in production. (Yes, some people still do that, unfortunately.)

The audience laughs at the idea of accidentally deleting a production database, but the point is dead serious: defensive defaults are the cheapest safety net.

[embed]

On typing, he’s direct: “Types are your AI best friend.” PHPStan, which he calls “the equivalent of TypeScript for PHP”, isn’t just a nice-to-have: it’s the tool that catches a missing property or a wrong return type across the whole project, not just the file currently on screen.

In the live demo, he removes a return type, runs composer test:type-coverage, and Pest immediately fails with the exact file and line. "This code won't go to production because it's missing a type on line 22."

That level of strict is what we aim for.

On testing, the message is even stronger: “If testing was important before, now with AI is even more important. It’s literally the last safeguard between the code generated by AI and production code.”

His testing pipeline isn’t just unit tests: it’s formatting, linting, type coverage, code coverage, and static analysis, all behind a single composer test script. The coverage gate is the star.

“You’re going to see a lot of experts on the field telling you that code coverage isn’t important. I’m going to tell you the opposite. In this era of AI, it’s the most important thing. It literally forces AI to have tests for all the generated code.”

Strict defaults, strong typing, and a test pipeline form the three legs of Nuno’s strict AI engineering argument.

Strict defaults, strong typing, and a test pipeline form the three legs of Nuno’s strict AI engineering argument.

The testing pipeline: one command to rule them all

If you want an agent to produce tested code, you can’t give the agent a list of commands. You just have to give it one command.

The fewer knobs, the fewer exit ramps.

The composer scripts in a strict Laravel project look like this:

{
  "scripts": {
    "lint": [
      "vendor/bin/rector",
      "vendor/bin/pint",
      "npm run lint"
    ],
    "test:lint": [
      "vendor/bin/pint --test",
      "npm run lint --check"
    ],
    "test:type-coverage": [
      "vendor/bin/pest --type-coverage --min=100"
    ],
    "test:types": [
      "vendor/bin/phpstan analyse",
      "npx tsc --noEmit"
    ],
    "test:unit": [
      "vendor/bin/pest --parallel --coverage --min=100"
    ],
    "test": [
      "@test:lint",
      "@test:type-coverage",
      "@test:types",
      "@test:unit"
    ]
  }
}

Each script has one job:

  • **composer lint** cleans and formats everything. Rector modernises PHP, Pint applies Laravel's style, and npm lint handles the frontend.
  • **composer test:lint** checks the formatting without changing files. Use this version in CI and hooks.
  • **composer test:type-coverage** asks Pest to fail unless every method argument, return, and property is typed. The Pest type coverage docs explain how --type-coverage scans the codebase and --min=100 enforces full coverage.
  • **composer test:types** runs PHPStan and TypeScript to catch cross-file errors. PHPStan is project-wide; TypeScript is the same idea for the frontend.
  • **composer test:unit** runs Pest with --parallel and --coverage --min=100. The Pest test coverage docs cover the flags, but the practical point is the gate: if the generated code isn't exercised by a test, the build fails.
  • **composer test** runs the lot. The agent can forget the details. It only needs to remember composer test.

The output of composer test should be deterministic. If the agent runs it after a change and it passes, the change is safe enough to push. If it fails, the agent must keep working. That's the entire philosophy in one shell command.

$ composer test
​
  ./vendor/bin/pint --test
  ..............................
​
  ./vendor/bin/pest --type-coverage --min=100
  Type coverage: 100.00%
​
  ./vendor/bin/phpstan analyse
  65/65 files
​
  ./vendor/bin/pest --parallel --coverage --min=100
  PASS  Tests/Feature/CreateNote
  PASS  Tests/Unit/NoteAction
  Coverage: 100.00%
​
  Tests: 124 passed, 100% coverage

One command runs lint, type coverage, type checks, and tests.

One command runs lint, type coverage, type checks, and tests.

Pre-push hooks: fail before the commit reaches CI

CI is the backstop, not the first line of defence. The first line should be the developer’s own machine (unless you have plenty of dollarbucks to spend on hosted CI runners.)

Pre-push hooks are the standard way to enforce this. They run the composer test pipeline before git push is allowed to continue, and they stop the push if any stage fails.

A minimal pre-push hook for a Laravel project looks like this:

#!/usr/bin/env bash
# .git/hooks/pre-push
set -euo pipefail
​
echo "Running pre-push checks..."
​
composer test
​
echo "Pre-push checks passed."

That’s the entire file. The set -euo pipefail means the script exits on any failed command. If composer test fails, the push is blocked. The agent can't skip it because the hook is part of the git machinery.

For real projects, you’d want a bit more. The pre-push hook should:

  • Scope checks to the change set. If the change only touches docs or images, skip the heavy gates. If the change touches the API and the web app, run both.
  • Run web and API checks in parallel. A Turborepo or monorepo can lint the frontend while the backend tests run.
  • Fail fast. If linting fails, don’t wait for the full test suite to finish.
  • Escape gracefully. A human should be able to bypass with git push --no-verify in a real emergency, but CI should still catch the failure.

A production-grade pre-push hook can also run a coverage gate. The pattern is straightforward: run the test suite, parse the total coverage, and fail if it’s below the threshold. The open-source specscore/specscore-cli project uses a shared scripts/coverage-gate.sh called by both CI and the pre-push hook, which keeps the threshold in one place. One recent commit shows the full setup: .githooks/pre-push runs gofmt, go vet, go build, go test, and then the coverage gate, while CI calls the same scripts/coverage-gate.sh. There's no reason the same pattern can't be done with Pest, Pint, and PHPStan.

If you use a package manager like Husky, the same hook lives in .husky/pre-push and runs npm run test or composer test. The principle is the same: the gate is local, automatic, and hard to forget.

The pre-push hook turns the local machine into the first quality gate.

The pre-push hook turns the local machine into the first quality gate.

Agent stop hooks: the gate at the end of the turn

Pre-push hooks catch bad code before it leaves the machine. Stop hooks catch bad code before the agent leaves the room. They’re the natural extension of the same idea into the agent’s own lifecycle.

Every major agent harness now exposes a Stop event. Claude does. Codex does. Devin does. When the agent decides it has finished the turn, the hook runs first and can refuse to let the turn end. The Stop hook in Codex, Claude Code, and similar tools receives the session context, can inspect the transcript, and can force the agent to continue. As Marcin Dudek explains, the key insight is that "A line in the prompt asking it to verify is a request. A hook on Stop is enforcement. The hook runs in the harness and the model gets no vote."

A stop hook for a strict Laravel project might look like this:

#!/usr/bin/env bash
# .claude/hooks/stop-checks.sh or .codex/hooks/stop-checks.sh
set -euo pipefail
​
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_DIR="$(cd "$SCRIPT_DIR/../.." && pwd)"
​
if [[ -n "${STOP_HOOK_ACTIVE:-}" ]]; then
  # We already forced one continuation; do not loop forever.
  exit 0
fi
​
# Only run if there are code changes.
if ! git -C "$PROJECT_DIR" diff --name-only | grep -qE '\.(php|ts|tsx)$'; then
  exit 0
fi
​
cd "$PROJECT_DIR"
​
if ! composer test; then
  echo "composer test failed. Fix the issues before finishing."
  exit 2
fi
​
exit 0

Exit code 2 is the blocking signal. The agent can’t stop. It must continue, fix the failures, and try again. The STOP_HOOK_ACTIVE variable is the escape hatch Marcin Dudek warns about: without it, an unsatisfiable gate loops forever. The hook should force one continuation, then stand down.

There’s a subtlety in tow. Stop hooks in Codex reportedly don’t fire reliably for all tool types today — apply_patch edits and many MCP tool calls may not pass through PreToolUse or Stop. For that reason, don't rely on stop hooks alone. Pair them with PostToolUse hooks that run lightweight lint checks after file edits, and with pre-push hooks that run the full composer test before any commit leaves the machine. The stop hook is the gate on the turn; the pre-push hook is the gate on the push; CI is the gate on the merge. They're layers, not replacements.

Stop hooks guard the turn, pre-push hooks guard the push, and CI guards the merge.

Stop hooks guard the turn, pre-push hooks guard the push, and CI guards the merge.

Codex as an example harness

Codex is the concrete harness I’ll use to make this actionable, but the pattern is the same for any agent. Codex reads hooks from .codex/hooks.json or [hooks] in .codex/config.toml. It supports SessionStart, UserPromptSubmit, PreToolUse, PostToolUse, Stop, and more. The official docs list the full set.

A Codex project config for strict Laravel testing might look like this:

{
  "hooks": {
    "Stop": [
      {
        "command": "bash .codex/hooks/stop-checks.sh"
      }
    ],
    "PostToolUse": [
      {
        "command": "bash .codex/hooks/post-tool-use.sh",
        "timeout": 60
      }
    ]
  }
}

The PostToolUse hook is lightweight. It runs Biome or Pint on the file that was just edited, and it injects the lint output back into the agent's context. The agent sees the warnings immediately and can fix them before the turn ends. The Stop hook runs the full composer test only at the end of the turn, so the heavy suite doesn't run after every keystroke.

The key is to make the agent’s path of least resistance the correct path. If composer test is easy to run and failure blocks the turn, the agent will run it. If it's hidden behind a long list of commands, the agent will skip it.

The same logic makes Nuno Maduro’s composer test command central to the live demo. The agent is asked to add a "notes" feature to a user profile. It generates the migration, model, factory, form request, action, controller, frontend, and tests. Then, at the end, it runs composer test and the whole quality pipeline passes.

The agent didn't need to know the individual tools.

It needed one command that meant "done".

100% coverage: the forcing function for tested generated code

Code coverage is a contested metric. A codebase with 100% coverage can still be full of bugs. But for the specific problem of AI-generated code, coverage isn’t about measuring quality. It’s about forcing the agent to write tests.

When the agent knows that --coverage --min=100 will fail any untested line, it'll generate a test for every generated line. It stops treating tests as an afterthought. In the Laralive demo, the agent generates a Note model, a CreateNote action, and a NoteController, and then it writes feature tests for the controller. Because the existing tests in the project are high quality, the generated tests are high quality too. The agent copies the conventions it sees.

There are two coverage-like metrics to enforce:

  • Code coverage: every line of code must be exercised by a test. Pest’s --coverage --min=100 enforces this.
  • Type coverage: every function argument, return, and property must have a type. Pest’s --type-coverage --min=100 enforces this.

The two work together. Code coverage catches untested code. Type coverage catches untyped code. An untyped method is easy for the agent to hallucinate into, and a test might not catch the hallucination. A type declaration and a coverage gate together make the hallucination impossible to merge.

If 100% feels too brutal for an existing codebase, start with a baseline. PHPStan and Pint both support baseline files. The baseline says: ignore the existing errors, but fail on new ones. The codebase slowly improves without a big-bang rewrite. For a brand-new project, start at 100%. For a legacy project, ratchet the threshold up by project or module until you get there. You know you will.

New projects can start at 100%; legacy projects ratchet the threshold up from a baseline.

New projects can start at 100%; legacy projects ratchet the threshold up from a baseline.

Harness-agnostic principles

No matter which harness you use, the same five principles apply:

  1. One command for quality. composer test, npm run check, make test — whatever the command is, it should be the single answer when someone asks "is this safe to push?"
  2. Hook into the agent’s lifecycle. Stop hooks, PostToolUse hooks, and pre-push hooks all enforce the same pipeline at different moments. The more moments you cover, the fewer escape routes.
  3. Prefer failing defaults. A framework that throws on silent failures is better than one that tries to be helpful by returning null. Laravel's shouldBeStrict() is the template here.
  4. Write conventions, not just prompts. The agent reads the existing code. If every file has declare(strict_types=1), final classes, typed properties, and form requests, the next file will too. A CLAUDE.md or AGENTS.md file is useful, but the code itself is the stronger teacher.
  5. Legacy gets a baseline, not a pass. Existing projects don’t need to be perfect tomorrow. They need to stop getting worse today. A baseline file and a ratchet threshold do that.

The framework choice accelerates these principles. Laravel isn’t the only one that has them, but it’s the one that has bundled them together recently. The nunomaduro/essentials package, PHPStan, Pest, Pint, and Rector are all part of the same toolchain. The Laravel AI SDK extends the same conventions to agent building. That's why Laravel is well suited to the agentic era: it's a web framework and a contract the agent can learn.

Putting it together: a minimal setup checklist

If you’re starting a project today, this is the order I would follow:

  1. Choose a framework with strict defaults. Laravel is the example here, but the same applies to any stack with strong typing, good static analysis, and a single test command.
  2. Install the quality toolchain. Pest with pest-plugin-type-coverage, PHPStan or Larastan, Laravel Pint, Rector, and TypeScript for the frontend.
  3. Add composer test and composer lint scripts. One command for the full pipeline, one for the cleanup pass.
  4. Write a pre-push hook. Run composer test before any push. Scope it to the change set if needed.
  5. Add a stop hook. Block the agent’s turn if composer test fails. Include STOP_HOOK_ACTIVE logic so it doesn't loop.
  6. Add a PostToolUse hook. Run lightweight checks after file edits. Pint for PHP, Biome or ESLint for JS/TS.
  7. Set coverage and type thresholds to 100% for new projects, or use a baseline for legacy ones.
  8. Document the conventions in code. The agent reads more files than docs. Use declare(strict_types=1), final classes, typed properties, and consistent patterns.
  9. Run the same pipeline in CI. GitHub Actions, GitLab CI, or whatever you use should call composer test too.
  10. Review the agent’s output before you merge. Hooks catch mistakes. They don’t replace human review.

The minimal setup: strict defaults, a single test command, pre-push and stop hooks, and 100% coverage gates.

Want the agent to keep working until the suite passes? Wire a stop hook to composer test and make it fail before the turn can end.

Related reading

[embed]Codex CLI has hooks now, stop stuffing AGENTS.md OpenAI quietly shipped lifecycle hooks for Codex CLI and they fill a gap that AGENTS.md never could. Here’s how to set…ai.sulat.com

[embed]Claude Code async hooks: what they are and when to use them A practical guide to the new “fire-and-forget” hook capability that just shipped… quietlyai.sulat.com

[embed]OpenCode: Auto-Lint Your AI Agent’s Code with a Post-Turn Biome Hook AI coding agents are fast. They’ll scaffold a feature, refactor a module, and wire up tests before you’ve finished your…ai.sulat.com

You can do more with this article

  • Clone the nunomaduro/essentials package and enable strict models on a fresh Laravel project.
  • Add composer test --parallel --coverage --min=100 to your GitHub Actions workflow and watch it fail on the first untyped method.
  • Read Nader’s agent hooks overview for a harness-agnostic map of SessionStart, PreToolUse, PostToolUse, and Stop events.
  • Try the Codex hooks reference to add a Stop hook that runs composer test before the agent says it's done.
  • If you run a legacy codebase, generate a PHPStan baseline today and refuse to merge any new untyped code.

메타데이터
post_id
e290fc617df0
slug
a-guide-to-strict-ai-engineering-in-the-agentic-era-e290fc617df0
url
https://ai.sulat.com/a-guide-to-strict-ai-engineering-in-the-agentic-era-e290fc617df0
canonical_url
https://ai.sulat.com/a-guide-to-strict-ai-engineering-in-the-agentic-era-e290fc617df0
author_url
https://medium.com/@jpcaparas
status
ok
fetched_at
2026-07-15 14:03:45