惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

The Cloudflare Blog
U
Unit 42
OSCHINA 社区最新新闻
OSCHINA 社区最新新闻
腾讯CDC
罗磊的独立博客
博客园 - 聂微东
博客园_首页
雷峰网
雷峰网
云风的 BLOG
云风的 BLOG
Jina AI
Jina AI
奇客Solidot–传递最新科技情报
奇客Solidot–传递最新科技情报
D
DataBreaches.Net
The GitHub Blog
The GitHub Blog
人人都是产品经理
人人都是产品经理
Y
Y Combinator Blog
量子位
Microsoft Azure Blog
Microsoft Azure Blog
阮一峰的网络日志
阮一峰的网络日志
小众软件
小众软件
月光博客
月光博客
T
The Exploit Database - CXSecurity.com
Google DeepMind News
Google DeepMind News
H
Help Net Security
O
OpenAI News
Blog — PlanetScale
Blog — PlanetScale
S
Security Affairs
S
Security @ Cisco Blogs
Microsoft Security Blog
Microsoft Security Blog
T
The Blog of Author Tim Ferriss
AI
AI
MongoDB | Blog
MongoDB | Blog
G
Google Developers Blog
MyScale Blog
MyScale Blog
Cyber Security Advisories - MS-ISAC
Cyber Security Advisories - MS-ISAC
D
Docker
Hugging Face - Blog
Hugging Face - Blog
D
Darknet – Hacking Tools, Hacker News & Cyber Security
S
Schneier on Security
Cloudbric
Cloudbric
H
Heimdal Security Blog
J
Java Code Geeks
N
News and Events Feed by Topic
Hacker News - Newest:
Hacker News - Newest: "LLM"
宝玉的分享
宝玉的分享
有赞技术团队
有赞技术团队
S
SegmentFault 最新的问题
让小产品的独立变现更简单 - ezindie.com
让小产品的独立变现更简单 - ezindie.com
爱范儿
爱范儿
I
Intezer
GbyAI
GbyAI

DEV Community

Authentication Security Deep Dive: From Brute Force to Salted Hashing (With Java Examples) Why AI Systems Don’t Fail — They Drift Spilling beans for how i learn for exam😁"Reinforcement Learning Cheat Sheet" I Replaced Chrome with Safari for AI Browser Automation. Here's What Broke (and What Finally Worked) How Python Borrows Other People's Work The $40 Architecture: Processing 1 Billion API Requests with 99.99% Uptime Vibe Coding: A Workflow Guide (From Zero to SaaS) Most webhook security guides protect the wrong side. The scary part is delivery. Headless CMS for TanStack Start: Build a Blog with Cosmic EU Age Verification App "Hacked in 2 Minutes" — What Actually Happened Comfy Cloud’s delete function does not actually remove files Running AI Models on GPU Cloud Servers: A Beginner Guide Event-driven media intelligence with AWS Step Functions and Bedrock I scored 500 AI prompts across 8 quality dimensions — here's what broke How to Call Google Gemini API from Next.js (Free Tier, No Backend Needed) The Portal Protocol: Reclaiming Human Connection in the Age of AI How to Fix Your Team's Scattered Knowledge Problem With a Self-Hosted Forum Intro to tc Cloud Functors: A Graph-First Mental Model for the Modern Cloud Designing Multi-Tenant Backends With Both Ownership and Team Access I Built a Neumorphic CSS Library with 77+ Components — Here's What I Learned PostgreSQL Performance Optimization: Why Connection Pooling Is Critical at Scale Cómo construí un SaaS multi-rubro para gestionar expensas en Argentina con FastAPI + Vue 3 🚀 I Built an Ethical Hacking Scanner Tool – Open Source Project I Replaced /usage and /context in Claude Code With a Single Statusline A Pythonic Way to Handle Emails (IMAP/SMTP) with Auto-Discovery and AI-Ready Design I Collected 8.9 Million Polymarket Price Points — Here's What I Found About How Markets Really Move EcoTrack AI — Carbon Footprint Tracker & Dashboard Everyone's Using AI. No One Agrees How. 5 self-hosted ebook managers worth trying in 2026 Building Your First AI Agent with LangChain: From Chatbot to Autonomous Assistant Common SOC 2 Failures (Real World) Stop Vibe-Checking Your AI App: A Practical Guide to Evals How to Use SonarQube and SonarScanner Locally to Level Up Your Code Quality Your Next To-Do App Is Dead — I Replaced Mine with an OpenClaw AI Sign a Nostr event in 60 lines of Python using coincurve — no nostr-sdk, no nbxplorer, no rust toolchain ITGC Audit Explained Like You’re in Big 4 Patch Tuesday abril 2026: Microsoft parcha 163 vulnerabilidades y un zero-day en SharePoint Stop scraping everything: a better way to track competitor price changes Listing on MCPize + the Official MCP Registry while routing payments OUTSIDE the marketplace — how I kept 100% of my x402 revenue Building an AI-Powered Risk Intelligence System Using Serverless Architecture Why We Ripped Function Overloading Out of Our AI Toolchain Testing AI-Generated Code: How to Actually Know If It Works SaaS Churn Is Killing Your Business. Here Is What to Do About It (Without a Support Team) The Speed of AI Is No Longer Linear - And Self-Improving Models Are Why How to Implement RBAC for MCP Tools: A Practical Guide for Engineering Teams From Standard Quote to Persuasive Proposal: AI Automation for Arborists I built a CLI that scaffolds complete multi-tenant SaaS apps Axios CVE-2025–62718: The Silent SSRF Bug That Could Be Hiding in Your Node.js App Right Now The dashboard that ended our friendship Data Pipelines Explained Simply (and How to Build Them with Python) The Hidden Cost of AI Systems Nobody Talks About. undefined vs undeclared, and how typeof behaves Switching from file-based jobs to NATS/Kafka in Rust without changing code io_uring Adventures: Rust Servers That Love Syscalls Why Agentic AI is Killing the Traditional Database The POUR principles of web accessibility for developers and designers Quantum Neural Network 3D — A Deep Dive into Interactive WebGL Visualization How To Install Caveman In Codex On macOS And Windows Automation Pipeline Reliability: Why Your Workflow Breaks When Nobody Is Watching I Built an 'Open World' AI Coding Agent — It Works From ANY Folder From Freelancing to Product: A Tech Service Company's SaaS Transformation China's AI Giants: Adding Tencent Hunyuan & ByteDance Doubao to AI University (74 Providers) On the Vibe Coders and Their Lies clerk: Auto-Summarize Your Claude Code Sessions AI Weekly — 2026/04/10–04/17 | The Model Lockdown Is Here, but the Toolchain Is the Real Battleground AI 週報 — 2026/04/10–2026/04/17 模型封鎖潮來了,但工具鏈才是真戰場 Maybe this is how Open-Source apps are born... 🚀 Fine-Tune LLMs with LoRA and QLoRA: 2026 Guide tRPC v11 + Next.js App Router: End-to-End Type Safety Without the Boilerplate ShadCN UI in 2026: Why I Stopped Installing Component Libraries and Started Owning My Components SaaS Billing in React Server Components: Stripe + Supabase Without a Single `useEffect` Join our DEV Weekend Challenge — $1,000 in Prizes Across TEN winners! Submissions Due April 20 at 6:59 AM UTC. Implementing FSRS Spaced Repetition in Flutter + Supabase — Adding Memory Science to an AI Learning App "I Texted My Localhost From the Train — Claude Code Fixed the Bug Before I Got Home" I Built a Sales Prep AI and It Went Deeper Than Expected Design to Code #2: One JSON, Eleven Outputs Solving the 100M-Row Problem: A Summary Table Pattern for High-Volume Push Notification Logs Flutter Web With Wasm: What Actually Changes For Developers I Built 50 Royalty-Free Soundtracks for My Side Project in a Weekend Using AI Music Generation The Vibe Coding Security Checklist: 7 Things to Check Before You Ship Stop Letting Googlebot Guess Fix Your React App's SEO Right Desconstruindo o Streaming do LinkedIn: Como Criar um Engine de Extração de Vídeo de Alta Performance com HLS e FFmpeg (EDA Part-1) EDA (Exploratory Data Analysis) Explained With Real Life — Why Looking at Your Data Is the Most Important Step in Machine Learning Brand Relationship Management at Scale: Our 4-Touch Outreach System for 200+ Brands Why String.fromEnvironment() Might Return an Empty String in Dart JGuardrails 1.0.0 — Hardening Java LLM Apps Against Jailbreaks, Toxicity, and Prompt Injection Plan and Schedule a Full Week of Threads Content From One Claude Conversation Coding Cat Oran Ep3, Five Tables Changed Everything Updated: BFF Pattern I'm done watching freelancers get buried by 200 proposals. So I'm building the alternative. This is my first post BFS Algorithm in Java Step by Step Tutorial with Examples Tracking LLM Pricing Monthly: An Open Dataset for 22 AI Models How We Measure Content ROI on a Comparison Site: Revenue Attribution Without Perfect Data Introducing Nova AI Ops: The AI-Native Operating System for SRE Teams I built a free desktop video downloader for Windows — Grabbit How Talkie OCR Helps Vision-Impaired & Dyslexic Users Read the World Around Them VRCFaceTracking安装和iPhone面捕配置教程,有bug Even CrowdStrike Can't See Your Agents The Automation Gold Rush: What n8n Workflows and Claude Are Opening Up for Developers Right Now
Clean Architecture's Four Rings, Translated to PHP
Gabriel Anha · 2026-05-19 · via DEV Community

You've seen the picture. Four concentric rings, the words Entities, Use Cases, Interface Adapters, Frameworks & Drivers arranged like a target, arrows pointing inward. It's on the cover of Robert C. Martin's Clean Architecture, in every architecture talk on YouTube, and pinned in someone's Slack right now.

The picture is beautiful. The problem is the picture doesn't tell you where to put PlaceOrderController.php. It doesn't tell you whether App\Domain\Order should hold the Doctrine attributes or not. It doesn't tell you what to do when Laravel's Request object shows up in a method signature.

This post does the translation. Four rings on the whiteboard, mapped to PHP namespaces and a directory tree you can paste into a src/ folder today.

The four rings, restated for PHP people

The rings aren't folder names. They're a rule about which way the arrows point: source-code dependencies cross ring boundaries inward, never outward. The outer rings know about the inner rings. The inner rings have never heard of the outer rings.

A target with four concentric rings labelled Entities, Use Cases, Interface Adapters, and Frameworks and Drivers, with arrows pointing inward

Reading from the center outward:

Ring 1 — Entities. Things the business cares about. Order, Customer, Invoice, Money. They encode rules of the business itself, not rules of this particular application. An Order refuses to transition from shipped back to pending. That's true whether the system is a website, a CLI, or a queue worker. Plain PHP. No Doctrine, no Eloquent, no framework imports.

Ring 2 — Use Cases. What this application does. PlaceOrder, CancelOrder, RefundPayment. One verb per class. Orchestrates entities and ports to deliver one business operation. Knows nothing about HTTP, CLI, queues, or which database is behind the repository.

Ring 3 — Interface Adapters. Translation. A controller takes an HTTP request and shapes it into the input the use case expects. A repository implementation takes a domain Order and shapes it into rows for Doctrine. The adapter ring does shape-shifting, not business logic.

Ring 4 — Frameworks and Drivers. Laravel, Symfony, Doctrine, Guzzle, PHP-FPM, the database, the message broker. The volatile, version-churning edge of the system. The things you have no control over and don't want to.

The promise: when Doctrine ships a major version, only ring 4 changes. Moving a service from Laravel 11 to Symfony 7 leaves rings 1 and 2 untouched.

Three namespaces, four rings

The four rings collapse cleanly into three folders on disk. Most PHP teams will end up here:

src/
  Domain/
    Order/
      Order.php
      OrderId.php
      OrderStatus.php
      OrderRepository.php          // port (interface)
      OrderAlreadyShipped.php      // domain exception
    Customer/
      Customer.php
      CustomerId.php
    Shared/
      Money.php
  Application/
    Order/
      PlaceOrder.php               // use case
      PlaceOrderInput.php          // input DTO
      PlaceOrderOutput.php         // output DTO
      CancelOrder.php
    Port/
      Clock.php                    // port (interface)
  Infrastructure/
    Http/
      Controller/
        PlaceOrderController.php
    Cli/
      PlaceOrderCommand.php
    Persistence/
      Doctrine/
        DoctrineOrderRepository.php
    Clock/
      SystemClock.php

Enter fullscreen mode Exit fullscreen mode

App\Domain is ring 1, plus the repository port interfaces written in the domain's own language. App\Application is ring 2: use cases, DTOs, and application-level ports. App\Infrastructure collapses rings 3 and 4 because in a real project the controller class and the framework that hosts it are not separable. PlaceOrderController lives in the same file as the framework's Request and Response types. Splitting "the controller class" from "the framework's HTTP types" into two directories produces a longer path and a fiction about purity that the IDE will not honor.

Three folders. The dependency rule still applies at the seam between rings 3 and 4 inside Infrastructure/.

Ring 1 in PHP: an entity that knows nothing

Here's App\Domain\Order\Order. It encodes what an order is. It does not encode what the system does when an order is placed.

<?php

declare(strict_types=1);

namespace App\Domain\Order;

use App\Domain\Customer\CustomerId;
use App\Domain\Shared\Money;
use DateTimeImmutable;

final class Order
{
    /** @var list<OrderLine> */
    private array $lines = [];

    private OrderStatus $status = OrderStatus::Pending;

    public function __construct(
        public readonly OrderId $id,
        public readonly CustomerId $customerId,
        public readonly DateTimeImmutable $placedAt,
    ) {}

    public function addLine(OrderLine $line): void
    {
        if ($this->status !== OrderStatus::Pending) {
            throw new OrderAlreadyShipped(
                'Cannot add lines after shipment.'
            );
        }
        $this->lines[] = $line;
    }

    public function total(): Money
    {
        return array_reduce(
            $this->lines,
            fn(Money $sum, OrderLine $l) => $sum->add($l->subtotal()),
            Money::zero('EUR'),
        );
    }

    public function markShipped(): void
    {
        if ($this->status === OrderStatus::Shipped) {
            return;
        }
        $this->status = OrderStatus::Shipped;
    }
}

Enter fullscreen mode Exit fullscreen mode

Notice what's missing. No extends Model, no #[ORM\Entity], no Illuminate\ or Symfony\ use statements. The only imports are other ring-1 types. Drop this file into a project whose composer.json requires only "php": "^8.3" and it parses and runs.

That's the test. If Order.php can stand alone with PHP and nothing else, ring 1 is honest. The day someone adds use Illuminate\Database\Eloquent\Model; at the top, ring 1 is leaking and the dependency rule is broken.

Ring 1, continued: the repository port

The OrderRepository interface lives in the domain too, even though every implementation will live in Infrastructure/. The reason is the dependency rule. The use case in ring 2 needs to ask for an order. It does that through an interface. That interface has to live in a ring the use case is allowed to import, which means ring 1 or ring 2, never ring 3 or 4.

<?php

declare(strict_types=1);

namespace App\Domain\Order;

interface OrderRepository
{
    public function find(OrderId $id): ?Order;

    public function save(Order $order): void;
}

Enter fullscreen mode Exit fullscreen mode

The interface is named for what the domain needs, not for the storage technology behind it. OrderRepository, not DoctrineOrderTable. The domain doesn't know there will be a database. The domain doesn't know there could be a database. From the domain's view, somewhere out there is something that can save and load orders. Who and how is somebody else's problem.

Ring 2 in PHP: a use case as a single verb

A use case is one application operation. One verb. One class. One public method.

<?php

declare(strict_types=1);

namespace App\Application\Order;

use App\Application\Port\Clock;
use App\Domain\Order\Order;
use App\Domain\Order\OrderId;
use App\Domain\Order\OrderRepository;

final class PlaceOrder
{
    public function __construct(
        private readonly OrderRepository $orders,
        private readonly Clock $clock,
    ) {}

    public function execute(PlaceOrderInput $in): PlaceOrderOutput
    {
        $order = new Order(
            id: OrderId::new(),
            customerId: $in->customerId,
            placedAt: $this->clock->now(),
        );

        foreach ($in->lines as $line) {
            $order->addLine($line);
        }

        $this->orders->save($order);

        return new PlaceOrderOutput(
            orderId: $order->id,
            totalCents: $order->total()->cents,
        );
    }
}

Enter fullscreen mode Exit fullscreen mode

This class is testable with three fakes and zero framework boot. No php artisan, no bin/console, no Doctrine bootstrap, no in-memory SQLite. The unit test that exercises PlaceOrder runs in single-digit milliseconds.

If your use-case test needs the framework to boot, ring 2 has been contaminated and you're paying for that contamination on every test run for the rest of the project's life.

Ring 3 in PHP: the controller is an adapter

The controller's job is translation. HTTP in, DTO in. DTO out, HTTP out. Business logic in between is a smell.

<?php

declare(strict_types=1);

namespace App\Infrastructure\Http\Controller;

use App\Application\Order\PlaceOrder;
use App\Application\Order\PlaceOrderInput;
use App\Domain\Customer\CustomerId;
use App\Domain\Order\OrderLine;
use App\Domain\Shared\Money;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Attribute\Route;

final class PlaceOrderController
{
    public function __construct(
        private readonly PlaceOrder $useCase,
    ) {}

    #[Route('/orders', methods: ['POST'])]
    public function __invoke(Request $request): JsonResponse
    {
        $body = json_decode($request->getContent(), true);

        $input = new PlaceOrderInput(
            customerId: new CustomerId($body['customer_id']),
            lines: array_map(
                fn(array $l) => new OrderLine(
                    sku: $l['sku'],
                    quantity: $l['quantity'],
                    unitPrice: Money::ofCents(
                        $l['unit_price_cents'],
                        'EUR',
                    ),
                ),
                $body['lines'],
            ),
        );

        $output = $this->useCase->execute($input);

        return new JsonResponse([
            'order_id' => (string) $output->orderId,
            'total_cents' => $output->totalCents,
        ], status: 201);
    }
}

Enter fullscreen mode Exit fullscreen mode

Read it carefully. The controller parses JSON, builds an input DTO, calls the use case, and serializes the output. No business rules anywhere in the class.

Swap Symfony for Laravel and the diff is in three lines: the #[Route] attribute, the Request type-hint, and the JsonResponse return type. The use case underneath does not change. The domain underneath that does not change either.

Rings 3 and 4 in PHP: where the framework finally shows up

Out at the edge, a single class straddles rings 3 and 4: Doctrine (ring 4) reached through an adapter (ring 3) that implements the OrderRepository port from ring 1.

<?php

declare(strict_types=1);

namespace App\Infrastructure\Persistence\Doctrine;

use App\Domain\Order\Order;
use App\Domain\Order\OrderId;
use App\Domain\Order\OrderRepository;
use Doctrine\DBAL\Connection;

final class DoctrineOrderRepository implements OrderRepository
{
    public function __construct(
        private readonly Connection $db,
    ) {}

    public function find(OrderId $id): ?Order
    {
        $row = $this->db->fetchAssociative(
            'SELECT id, customer_id, placed_at FROM orders WHERE id = ?',
            [(string) $id],
        );
        if ($row === false) {
            return null;
        }
        return OrderMapper::toDomain($row);
    }

    public function save(Order $order): void
    {
        $this->db->executeStatement(
            'INSERT INTO orders (id, customer_id, placed_at, total_cents)
             VALUES (?, ?, ?, ?)
             ON CONFLICT (id) DO UPDATE SET total_cents = EXCLUDED.total_cents',
            [
                (string) $order->id,
                (string) $order->customerId,
                $order->placedAt->format('c'),
                $order->total()->cents,
            ],
        );
    }
}

Enter fullscreen mode Exit fullscreen mode

This class imports Doctrine. The class it implements (OrderRepository) does not. The arrow points inward: InfrastructureDomain. The domain has no idea Doctrine exists. Replace it with a PdoOrderRepository, a DynamoDbOrderRepository, or an InMemoryOrderRepository for tests, and the change is contained to one file in one folder.

That's the payoff. The whole onion drawing exists to make this property true.

The dependency rule, mechanized

A rule that depends on every reviewer remembering it is not a rule. It's a hope. Enforce the dependency rule with Deptrac or PHPArkitect in CI, with a config like this:

# deptrac.yaml
deptrac:
  paths:
    - ./src
  layers:
    - name: Domain
      collectors:
        - { type: classLike, value: ^App\\Domain\\.* }
    - name: Application
      collectors:
        - { type: classLike, value: ^App\\Application\\.* }
    - name: Infrastructure
      collectors:
        - { type: classLike, value: ^App\\Infrastructure\\.* }
  ruleset:
    Domain: []
    Application:
      - Domain
    Infrastructure:
      - Domain
      - Application

Enter fullscreen mode Exit fullscreen mode

Domain depends on nothing. Application may depend on Domain. Infrastructure may depend on both. Anything else is a build failure. Push a controller import into your domain and CI fails before review.

A directory tree with arrows showing dependencies pointing only inward from Infrastructure to Application to Domain

A test on your current codebase

Open a feature folder in your current project. Sort every file into three buckets.

Bucket A — pure PHP. Loadable by Composer with "require": { "php": "^8.3" } and nothing else. Imports are PHP built-ins plus your own namespaces. Open each file. Read the use statements. Verify.

Bucket B — application files contaminated by the framework. A use case that imports Illuminate\Support\Facades\DB. A "service" that calls Cache::remember(...). A class extending Symfony\Component\Console\Command\Command but containing business logic inside execute() instead of delegating to a use case class.

Bucket C — framework glue. Routes, controllers that do nothing but call other code, command classes, event listeners registered by attribute, service provider configuration.

Write the three counts down. A healthy application has bucket A as the largest: domain plus use cases, in pure PHP, no framework imports below the controller layer. Bucket C is smaller and lives entirely in Infrastructure/. Bucket B should be small or empty. Every file in bucket B is a refactor candidate: pull the business logic into a use case in bucket A, leave a thin adapter in bucket C.

If bucket B is the largest, the framework is the application.

What the picture is, and isn't

The four rings are a thinking tool. The dependency rule is the law. The directories are tactics that follow from both.

You don't need four folders. You probably don't even need three if the application is small enough. What you need is the property that swapping Doctrine for PDO, or Laravel for Symfony, or your HTTP framework for a Slack-bot adapter, doesn't ripple all the way into the class that knows what an order is.

If that property holds, the picture has done its job. Without it, no amount of App\InterfaceAdapters\Presenters\OrderPlacementSuccessPresenterFactory will save you.


If this was useful

The full PHP application, with every ring built out, every port and adapter wired, and a strangler-pattern migration from a legacy Laravel service into this shape, is the spine of Decoupled PHP. It walks the four rings from a single-file CRUD up to a production service with HTTP, CLI, queue workers, Doctrine, an external payment gateway, and a test suite that runs without booting the framework.

Decoupled PHP — Clean and Hexagonal Architecture for Applications That Outlive the Framework

Available on Kindle, Paperback, and Hardcover. English, German, and Japanese editions out now — Portuguese and Spanish coming soon.