Why I Built a Lightweight WordPress Plugin to Manually Control JSON-LD Schema?

Structured data (JSON-LD) is one of the most important parts of modern SEO. It helps search engines understand content context and enables rich results like FAQs, articles, and product snippets.

However, working with WordPress at scale exposes a consistent problem:

The problem with automated schema generation

Most SEO plugins like Yoast or Rank Math automatically generate schema in the background. While this is useful for beginners, it introduces serious limitations in advanced setups:

  • Duplicate schema across plugins
  • Conflicting JSON-LD structures
  • Limited control over schema output
  • Difficulty combining multiple schema types on a single page
  • Lack of visibility into what is actually being injected

In complex WordPress environments, these issues often result in invalid or ignored structured data—even when everything appears “correct” in the UI.

What I needed instead

I wanted a workflow that gives full control over structured data without relying on hidden automation layers.

Specifically:

  • Manually define JSON-LD schema per post or page
  • Combine multiple schema types cleanly
  • Validate basic structural and duplicate issues instantly
  • Avoid conflicts with existing SEO plugins
  • Keep the system lightweight and predictable

The solution: BBH Custom Schema

To solve this, I built a lightweight WordPress plugin called BBH Custom Schema.

It is designed for developers and technical SEO workflows where control matters more than automation.

Key capabilities:

  • Manual JSON-LD schema injection on posts, pages, and custom post types
  • Schema combining system for multiple structured data blocks
  • Basic validation to detect formatting issues and duplicate entries
  • Conflict-safe implementation alongside SEO plugins like Yoast SEO and Rank Math
  • Lightweight architecture with no unnecessary frontend overhead

Design philosophy

This plugin is not trying to “automate SEO”.

Instead, the goal is:

Give developers full visibility and control over structured data output.

That means:

  • No hidden schema injection
  • No silent overrides
  • No opinionated automation logic

Just explicit, controlled, structured data management.

When this approach makes sense

This workflow is useful when:

  • You are managing multiple schema types per page
  • You need strict control over structured data output
  • You are debugging rich result issues
  • You are working in SEO-heavy or enterprise WordPress environments

What I learned building it

The biggest insight was simple:

Schema problems in WordPress are rarely about missing data—they are about conflicting data.

Most “invalid schema” issues in Google tools are actually caused by:

  • duplication
  • overlapping plugins
  • inconsistent injection order

Not the schema itself.

Plugin link

BBH Custom Schema
https://wordpress.org/plugins/bbh-custom-schema/

Final note

This project started as a small internal tool to solve schema conflicts in real projects. It evolved into a reusable plugin for anyone who needs precise control over structured data in WordPress.

From Logic to Code: Understanding the Curry–Howard Correspondence in Lean

For many programmers, precise mathematical logic and general programming feel like different worlds.

  • Logic is about proving theorems.
  • Programming is about making systems behave correctly.
  • Proofs belong to mathematicians.
  • Functions belong to engineers.

But in systems like Lean, those boundaries start collapsing.

One of the deepest ideas behind this is the Curry–Howard correspondence — the observation that:

  • logical propositions correspond to types
  • proofs correspond to programs
  • proof checking corresponds to type checking

At first, this sounds abstract, philosophical, or even like category theory mysticism.

But once you see it expressed concretely in Lean, it becomes quite mechanical.

This article walks through that transition step by step:

  • from logic,
  • to functions,
  • to proof goals,
  • to types carrying specifications,
  • to a sorting function that literally contains its own correctness proof.

The Core Idea: Propositions Behave Like Types

Let us start with a tiny logical statement:

P ∧ Q → P

Read logically, this means:

“If P and Q are both true, then P is true.”

Very obvious.

Now let us reinterpret it computationally.

Logical Interpretation

To prove:

P ∧ Q

you must provide:

  • a proof of P
  • and a proof of Q

That structure is identical to a pair:

(p, q)

Similarly, to prove:

P → Q

you must provide:

  • a method that transforms evidence of P
  • into evidence of Q

That is exactly what a function does.

So under Curry–Howard:

Logic Programming
proposition type
proof value/program
implication function
conjunction (P ∧ Q) pair
proof checking type checking

This is not metaphorical in Lean.

It is literal.

A Tiny Theorem Is Also a Program

Here is a theorem in Lean:

theorem fst : P ∧ Q → P := by
  intro h
  exact h.left

Let us read this slowly.

The Goal

Initially Lean shows:

⊢ P ∧ Q → P

Everything after ⊢ is the current proof goal.

You can think of it as:

“Construct something of this type.”

Step 1: intro h

intro h

This assumes:

h : P ∧ Q

and changes the goal to:

⊢ P

So now we have evidence that:

  • P is true
  • Q is true

bundled inside h.

Step 2: Extract the Left Side

exact h.left

This extracts the proof of P.

Done.

Now look at the exact same theorem computationally.

The theorem is equivalent to:

fun h => h.left

This is just:

  • take a pair
  • return the first element

The same object is simultaneously:

  • a proof,
  • and a program.

That is Curry–Howard.

Lean Proof Goals Are Requests for Evidence

A useful way to think about Lean is:

Lean is constantly asking you for evidence.

Suppose Lean shows:

⊢ Sorted ys

This is not a boolean check.

Lean is not asking:

“Is this true?”

It is asking:

“Can you construct evidence that this is true?”

That distinction matters enormously.

In ordinary programming:

  • correctness is often informal,
  • hidden in comments,
  • or validated through testing.

In Lean:

  • correctness becomes part of the type system itself.

Types Can Express Specifications, Not Just Data Shapes

Most programmers are used to types like:

sort : List Nat -> List Nat

This only says:

  • input: list
  • output: list

Nothing about correctness.

The function could:

  • reverse the list,
  • shuffle the list,
  • return garbage,
  • or always return [].

The type would still accept it.

Now look at this:

sort :
  (xs : List Nat) ->
  { ys : List Nat //
      Sorted ys ∧ Permutation xs ys }

This changes everything.

The function is now required to produce:

  1. an output list ys
  2. a proof that ys is sorted
  3. a proof that ys contains exactly the same elements as xs

The type stopped describing shape and started describing truth.

“Sorted” Becomes a Logical Object

But where does Sorted come from?

It must itself be formally defined.

Here is a simplified version:

inductive Sorted : List Nat -> Prop
| nil :
    Sorted []

| single (x : Nat) :
    Sorted [x]

| cons (x y : Nat) (xs : List Nat) :
    x ≤ y ->
    Sorted (y :: xs) ->
    Sorted (x :: y :: xs)

This says:

  • empty lists are sorted
  • single-element lists are sorted
  • larger lists are sorted if:

    • the first element is ≤ the second
    • and the tail is sorted

Now suppose your implementation returns:

[1,2,3]

Lean must construct evidence for:

Sorted [1,2,3]

That proof is itself a structured object.

Roughly:

1 ≤ 2
2 ≤ 3
tail sorted

all recursively assembled together.

What Happens When the Function Is Wrong?

Suppose the implementation returns:

[3,1,2]

Lean now tries to construct:

Sorted [3,1,2]

But according to the definition of Sorted, this eventually requires:

proof(3 ≤ 1)

Impossible.

So the program is rejected.

Notice what happened here.

We did not:

  • run tests,
  • inspect runtime output,
  • fuzz inputs,
  • or hope for good behavior.

Instead:

  • the implementation failed to inhabit the required type.

The proof could not be constructed.

So the program was invalid.

The Deep Shift: Correctness Became Construction

This is the conceptual leap Curry–Howard enabled.

Traditionally:

  • programs were operational artifacts,
  • proofs were human reasoning artifacts.

Curry–Howard showed they share the same structure.

That insight led to:

  • proof assistants,
  • dependently typed programming,
  • formally verified kernels,
  • verified compilers,
  • machine-checked mathematics,
  • cryptographic verification systems.

The key shift is this:

A proof is not merely an argument.

It is a concrete computational object.

And in Lean:

  • writing a program
  • and proving it correct

can become the same act.

Closing Thoughts

When people first hear:

“propositions are types”

“proofs are programs”

it often sounds like abstract mathematical poetry.

But Lean makes the idea concrete.

A theorem becomes a type.
A proof becomes a value.
A function becomes logical implication.
And type checking becomes proof verification.

The result is a system where correctness is not merely documented or tested after the fact.

It becomes part of what the program is.

That is the real significance of the Curry–Howard correspondence:

  • not philosophy for its own sake,
  • but a bridge between logic and executable systems.

And once you see it inside Lean, it becomes difficult to unsee.

Internal vs External Load Balancer: Key Differences Explained


---

## The Core Difference in One Sentence

**External (Internet facing) Load Balancer:** Balances traffic coming from the public internet to resources inside your private network.

**Internal (Private) Load Balancer:** Balances traffic coming from inside your private network to other resources also inside your private network.

![ ](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/hoyv2w8bjpnv92pv231o.png)


---

## External (Internet Facing) Load Balancer

### How it works

It has a public IP address and is accessible from the internet. Clients (users, mobile apps, external APIs) send requests to this public IP, and the load balancer distributes those requests to backend servers (like web servers or application servers) inside your Virtual Private Cloud (VPC).

### Common Use Cases

- Hosting a public website or e-commerce site
- Exposing a public REST API to external developers
- Serving a mobile app's backend

### Example

Users in different countries typing `https://yourapp.com` → DNS resolves to a public load balancer IP → Load balancer forwards requests to healthy web servers in your VPC.

### Security Note

External load balancers often sit at the "edge" and are paired with security groups, Web Application Firewalls (WAF), and SSL/TLS termination.

---

## Internal (Private) Load Balancer

### How it works

It has only a private IP address (no public internet access). It routes traffic within your VPC or data center. Clients are typically other internal services, databases, or application components.

### Common Use Cases

- **Microservices communication:** Service A calls Service B internally
- **Database load balancing:** Distributing read queries across database replicas
- **Internal API gateways:** Exposing APIs only to other internal apps
- **Tiered architectures:** Web tier (external LB) → App tier (internal LB) → Database tier

### Example

Your internal order processing service needs to call the inventory service. An internal load balancer sits in front of three inventory service instances, distributing requests and hiding failures — all without ever being reachable from the internet.

---

## Side by Side Comparison

| Feature | External Load Balancer | Internal Load Balancer |
|---------|------------------------|------------------------|
| **IP Address** | Public IP | Private IP only |
| **Internet Access** | Yes, accessible from internet | No, not accessible from internet |
| **Traffic Source** | External users, mobile apps, third-party APIs | Internal services, microservices, databases |
| **Typical Use** | Public websites, external APIs | Internal microservices, database clustering |
| **Security** | Requires WAF, SSL/TLS, DDoS protection | Only internal security groups needed |
| **DNS** | Public DNS name | Private DNS name |
| **Subnet** | Public subnet | Private subnet |

---

## Real World Architecture Example

    Internet user
          │
          ▼
┌─────────────────────┐
│ External Load       │  (public IP, in public subnet)
│ Balancer            │
└─────────────────────┘
          │
          ▼
┌─────────────────────┐
│ Web servers         │  (in private subnet)
└─────────────────────┘
          │
          ▼
┌─────────────────────┐
│ Internal Load       │  (private IP only, in private subnet)
│ Balancer            │
└─────────────────────┘
          │
          ▼
┌─────────────────────┐
│ Application servers │  (in private subnet)
│ + Database replicas │
└─────────────────────┘

- The **External LB** handles raw HTTPS from users
- The **Internal LB** distributes API calls from web servers to app servers — never exposed to the internet

---

## Code Example: AWS Load Balancers

### External Load Balancer (Public)

javascript
// AWS CDK – External Application Load Balancer
import * as elbv2 from ‘aws-cdk-lib/aws-elasticloadbalancingv2’;

const externalLB = new elbv2.ApplicationLoadBalancer(this, ‘ExternalLB’, {
vpc: vpc,
internetFacing: true, // External – public IP
loadBalancerName: ‘public-web-lb’
});

// Add listener for HTTPS traffic
externalLB.addListener(‘HTTPS’, {
port: 443,
certificates: [certificate],
defaultAction: elbv2.ListenerAction.forward([webTargetGroup])
});


### Internal Load Balancer (Private)

javascript
// AWS CDK – Internal Application Load Balancer
const internalLB = new elbv2.ApplicationLoadBalancer(this, ‘InternalLB’, {
vpc: vpc,
internetFacing: false, // Internal – private IP only
loadBalancerName: ‘internal-api-lb’
});

// Add listener for internal traffic
internalLB.addListener(‘HTTP’, {
port: 8080,
defaultAction: elbv2.ListenerAction.forward([appTargetGroup])
});




---

## When to Use Which?

### Choose External Load Balancer when:

- Your users are on the public internet
- You're hosting a public website or API
- Mobile apps need to connect to your backend
- You need SSL/TLS termination for external traffic

### Choose Internal Load Balancer when:

- Services within your network need to communicate
- You're implementing microservices architecture
- You need database read replica distribution
- You want to keep internal traffic private and secure

---

## Common Cloud Provider Names

| Provider | External LB Name | Internal LB Name |
|----------|------------------|------------------|
| **AWS** | Internet-facing ALB/NLB | Internal ALB/NLB |
| **Azure** | Public Load Balancer | Internal Load Balancer |
| **GCP** | External HTTP(S) LB | Internal TCP/UDP LB |

---

## Key Takeaway

| Type | Traffic Source | Accessibility |
|------|----------------|---------------|
| **External** | Public traffic from outside your network | Internet accessible |
| **Internal** | Private traffic from inside your network | Only within VPC |

> **Choose External** when your users are on the internet.
>
> **Choose Internal** when services within your network need to talk to each other reliably at scale.

---

**Written by Kashaf Abdullah**

*Software Engineer | MERN Stack | Web Development*

---

JavaScript Performance: 8 Fixes That Actually Matter (2026)

JavaScript Performance: 8 Fixes That Actually Matter (2026)

Not all optimizations are worth your time. These 8 are.

The Hierarchy of Performance

Before optimizing, ask:
1. Is anyone actually complaining about speed?
2. Have you measured where the slowdown is?
3. Is this optimization worth the complexity?

Rule of thumb: Optimize the hot path first.

Fix #1: Debounce and Throttle User Events

The #1 performance mistake I see: running expensive operations on every keystroke/scroll.

// ❌ BAD — Runs on every keystroke
input.addEventListener('input', (e) => {
  searchAPI(e.target.value); // API call on every key press!
});

// ✅ GOOD — Waits until user stops typing
function debounce(fn, delay = 300) {
  let timer;
  return (...args) => {
    clearTimeout(timer);
    timer = setTimeout(() => fn(...args), delay);
  };
}

input.addEventListener('input', debounce((e) => {
  searchAPI(e.target.value);
}, 300));

// ✅ GOOD — Runs at most once per interval (for scroll/resize)
function throttle(fn, interval = 100) {
  let lastTime = 0;
  return (...args) => {
    const now = Date.now();
    if (now - lastTime >= interval) {
      lastTime = now;
      fn(...args);
    }
  };
}

window.addEventListener('scroll', throttle(checkScrollPosition, 100));

Impact: Can reduce function calls from thousands to dozens per second.

Fix #2: Lazy Load Images and Components

<!-- Just add loading="lazy" -->
<img src="photo.jpg" alt="..." loading="lazy" width="800" height="600">

<!-- For background images or dynamic content -->
<div data-src="heavy-image.jpg" class="lazy"></div>
// Intersection Observer (no library needed)
const observer = new IntersectionObserver((entries) => {
  entries.forEach(entry => {
    if (entry.isIntersecting) {
      const el = entry.target;
      if (el.dataset.src) el.src = el.dataset.src;
      if (el.dataset.style) el.style.backgroundImage = `url(${el.dataset.style})`;
      el.classList.add('loaded');
      observer.unobserve(el);
    }
  });
}, { rootMargin: '100px' }); // Start loading 100px before visible

document.querySelectorAll('.lazy').forEach(el => observer.observe(el));

Impact: Reduces initial page load by 30-70% for image-heavy pages.

Fix #3: Use requestAnimationFrame for Visual Updates

// ❌ BAD — May cause layout thrashing
function updatePosition() {
  element.style.left = calculateNewX();
  element.style.top = calculateNewY();
  requestAnimationFrame(updatePosition);
}

// ✅ GOOD — Syncs with browser's paint cycle
function updatePosition() {
  const x = calculateNewX();
  const y = calculateNewY();

  requestAnimationFrame(() => {
    element.style.transform = `translate(${x}px, ${y}px)`;
  });

  requestAnimationFrame(updatePosition);
}

Also use requestIdleCallback for non-urgent work:

// Do heavy work when browser is idle
requestIdleCallback(deadline => {
  while (deadline.timeRemaining() > 0 && tasks.length > 0) {
    processTask(tasks.shift());
  }
});

Fix #4: Avoid Layout Thrashing

Layout thrashing = forcing browser to recalculate layout repeatedly.

// ❌ BAD — Reads/writes in loop (forces reflow each iteration)
elements.forEach(el => {
  console.log(el.offsetHeight);  // READ → forces reflow
  el.style.width = el.offsetWidth + 10 + 'px';  // WRITE → then read again
});

// ✅ GOOD — Batch reads, then batch writes
// First: all reads
const heights = elements.map(el => el.offsetHeight);

// Then: all writes
elements.forEach((el, i) => {
  el.style.width = heights[i] + 10 + 'px';
});

Tools: Chrome DevTools → Rendering → “Paint flashing” to see repaints.

Fix #5: Efficient DOM Manipulation

// ❌ BAD — Multiple DOM insertions
const list = document.getElementById('list');
items.forEach(item => {
  const li = document.createElement('li');
  li.textContent = item.name;
  list.appendChild(li);  // Reflow each time
});

// ✅ GOOD — Single insertion with DocumentFragment
const fragment = new DocumentFragment();
items.forEach(item => {
  const li = document.createElement('li');
  li.textContent = item.name;
  fragment.appendChild(li);
});
list.appendChild(fragment);  // Single reflow

// ✅ BETTER — innerHTML (for simple cases)
list.innerHTML = items.map(item => 
  `<li>${escapeHtml(item.name)}</li>`
).join('');

For frequent updates, consider Virtual DOM libraries or template literals with smart diffing.

Fix #6: Optimize Loops

const arr = [/* 10,000+ items */];

// ❌ SLOW — forEach with callback overhead
arr.forEach(item => { /* ... */ });

// ❌ SLOWER — for...of (iterator protocol overhead)
for (const item of arr) { /* ... */ };

// ✅ FASTEST — Traditional for loop (for hot paths)
for (let i = 0; i < arr.length; i++) {
  const item = arr[i];
  // ...
}

// ✅ ALSO FAST — Length cached (if array might change)
for (let i = 0, len = arr.length; i < len; i++) {
  // ...
}

Reality check: For most apps, this difference is negligible. Only optimize loops that run 10,000+ times per second.

Fix #7: Memoize Expensive Computations

// Simple memoization
function memoize(fn) {
  const cache = new Map();
  return (...args) => {
    const key = JSON.stringify(args);
    if (cache.has(key)) return cache.get(key);
    const result = fn(...args);
    cache.set(key, result);
    return result;
  };
}

// Usage
const expensiveCalculation = memoize((n) => {
  console.log('Computing...');
  return n * n * n; // Imagine this is slow
});

expensiveCalculation(5); // "Computing..." → 125
expensiveCalculation(5); // → 125 (from cache!)
expensiveCalculation(5); // → 125 (from cache!)

// React equivalent: useMemo / React.memo
const ExpensiveComponent = React.memo(({ data }) => {
  const result = useMemo(() => processData(data), [data]);
  return <div>{result}</div>;
});

Fix #8: Web Workers for Heavy Computation

// main.js
const worker = new Worker('worker.js');

worker.postMessage({ type: 'PROCESS', data: largeDataset });

worker.onmessage = (e) => {
  if (e.data.type === 'RESULT') {
    renderResults(e.data.result);
  }
};

// worker.js
self.onmessage = (e) => {
  if (e.data.type === 'PROCESS') {
    const result = heavyComputation(e.data.data);
    self.postMessage({ type: 'RESULT', result });
  }
};

Use for: Image processing, crypto operations, large dataset analysis, real-time calculations.

What NOT To Optimize

❌ Don't optimize:
   • Code readability for marginal gains
   • One-time operations (startup, config loading)
   • Code that runs < 10ms total
   • Before measuring (you'll guess wrong 80% of the time)

✅ DO optimize:
   • Render loops (animation, scroll handlers)
   • Large list rendering (virtualize instead)
   • API response processing (big JSON payloads)
   • Event handlers that fire frequently
   • Anything users complain about being "slow"

Measuring Performance

// Quick measurement
console.time('operation');
doSomething();
console.timeEnd('operation'); // operation: 123ms

// More precise
const start = performance.now();
doSomething();
const duration = performance.now() - start;
console.log(`Took ${duration.toFixed(2)}ms`);

// In production: Performance Observer
const observer = new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    if (entry.duration > 100) {
      logSlowOperation(entry.name, entry.duration);
    }
  }
});
observer.observe({ entryTypes: ['measure'] });

What’s the biggest performance win you’ve ever found?

Follow @armorbreak for more practical JS guides.

The Best Next.js SaaS Starter Kits with a Built-in CMS (2026)

It’s month two of your SaaS. Your co-founder pings you on Slack: “Can we change the hero headline? I think it’s confusing.” You open the repo, edit a string, push, wait for Vercel, redeploy. Five minutes for a typo.

Three weeks in, you’ve done this fifteen times.

This is the dirty secret of Next.js SaaS boilerplates. Almost all of them ship “a CMS” that turns out to be a folder of MDX files. Great for a developer who loves Git. Catastrophic for anyone else on the team — a non-technical co-founder, a marketing hire, a freelance copywriter — who needs to update content without going through a pull request.

Once you actually need a content workflow, you have three options:

  1. Integrate a headless CMS as a separate service (Contentful, Sanity, Storyblok). Adds infrastructure, a recurring bill, another vendor.
  2. Build one yourself. Weeks of work. Custom admin UI, role-based permissions, image uploads, draft/publish states, preview, version history. You will get this wrong the first time.
  3. Pick a starter kit where the CMS is already built in. Cheapest, fastest, and the only option a sane solo founder should consider in 2026.

This article is about option three. I looked at the Next.js SaaS starter kit market and asked one question: who can actually run the marketing site without touching code?

Most kits don’t make the cut. The ones that do break into three tiers, and the gap between tier 1 and tier 3 is enormous.

What counts as a “real” CMS in a starter kit

Before the ranking, here are the criteria. There are three tiers of CMS capability in this market, and the difference between them is what a non-developer can actually do on day one.

Tier 1 — Visual page builder / drag-and-drop CMS. A non-developer can build, edit, and publish marketing pages from an admin UI. Sections are pre-built components, content is editable inline, no Git involved. This is what most people picture when they say “CMS.”

Tier 2 — Integrated headless CMS. A real CMS UI (typically Sanity Studio or similar) is wired into the kit. A non-developer can edit structured content (blog posts, testimonials, FAQs), but page layout is still code. You’re not paying for the CMS as a separate SaaS — it ships with the kit.

Tier 3 — MDX blog. Markdown files in a /content folder. Useful for technical blogs, useless for anyone who doesn’t use Git. Some kits dress this up with extra content types (changelog, roadmap), but the workflow stays dev-only.

Kits that ship none of the above don’t make the list. I’ll cover those briefly at the end for completeness.

A real-world test: changing the hero headline

Before the ranking, here’s the test I keep coming back to. Imagine your co-founder, who doesn’t code, wants to A/B test two hero headlines. What happens in each tier?

  • Tier 1 (visual builder): Log into the admin panel, click the hero section, type the new headline, save. Live in 30 seconds.
  • Tier 2 (headless CMS): Log into the CMS dashboard, find the “Homepage” document, edit the heroTitle field, publish. Live in a minute or two — but only if the developer set up that field as editable content, which is a one-time piece of work that may or may not be done.
  • Tier 3 (MDX blog): Slack the developer. Wait. The developer edits a .tsx file, commits, pushes, waits for the deploy. The headline is hardcoded, so they may have to refactor it before they can change it. Live in 20 minutes to 2 hours.

That’s the difference between a CMS and a Git workflow with extra steps. Keep this scenario in mind as you read.

Here are the six kits with a real content layer, ranked by depth.

1. RapidLaunch — Tier 1 (Visual Page Builder)

Price: $69 (Starter) / $119 (Pro/Unlimited) — one-time
URL: rapidlaun.ch
CMS type: Drag-and-drop visual page builder

RapidLaunch is the only kit on this list with a full visual page builder out of the box. You get 25+ templates, 35+ customizable components, 20+ section templates, a visual navigation editor, a footer editor, and a media library. There’s also an AI template generator that scaffolds a complete site layout from a business description.

What a non-developer can do on day one:

  • Build a landing page from pre-made sections (hero, pricing, FAQ, testimonials, feature grid, etc.) by dragging them into place
  • Edit copy, swap images, change CTAs without touching code
  • Edit the main navigation and footer through dedicated editors
  • Upload and manage images through the media library
  • Toggle Coming Soon mode or Maintenance mode with one click

This is the kind of workflow most teams don’t get until they pay for Webflow ($30+/month) or stand up a headless CMS as a separate service. RapidLaunch ships it as part of the kit.

The rest of the stack (carried over from the wider comparison): Next.js 16 with App Router, TypeScript in strict mode, Supabase for database/auth/storage, Stripe and LemonSqueezy (choose via env var), Tailwind CSS + DaisyUI with 15+ theme presets, TipTap rich text editor, TanStack Query, Vitest + Playwright. Authentication includes Owner + Admin roles (RBAC).

Trade-offs: Supabase is the only database option. Auth is email/password only out of the box — Supabase supports social OAuth, but you’d add it yourself. The project is newer than ShipFast or Makerkit, so the community is smaller (though growing).

Verdict: The only kit on this list where a non-developer can run the marketing site from day one. If editing pages without a deploy is a requirement, this is the pick.

2. Indie Starter — Tier 2 (Sanity CMS Integration)

Price: $69 (Tier 1) / $199 (Tier 2) / $299 (Tier 3) — one-time, unlimited projects
URL: indie-starter.dev
CMS type: Integrated Sanity Studio

Indie Starter wires Sanity CMS into the kit so you get a real headless CMS UI without setting it up yourself. Most kits with a “blog” mean MDX files. Indie Starter means a hosted CMS dashboard where you can model content types, edit posts in a rich editor, schedule publishing, and manage media — the standard Sanity workflow.

What a non-developer can do on day one:

  • Edit blog posts, articles, and any custom content types in Sanity Studio
  • Upload and manage images
  • Preview content before publishing
  • Schedule posts

What they can’t do: edit landing-page layout. Marketing pages are still code. The CMS handles structured content, not visual page composition.

The rest of the stack: Next.js (latest), TypeScript, Supabase (PostgreSQL), magic links + Google/GitHub OAuth, Stripe with webhooks, Tailwind CSS + Shadcn/ui, Resend, Umami + Google Analytics, Zod. SEO infrastructure (sitemaps, robots.txt, Schema.org) is built in.

Trade-offs: Sanity is a separate service. The integration is free, but Sanity has its own free-tier limits (3 users, 10K documents, 5GB assets) — if you outgrow them, that’s a separate bill. Some features are tier-locked, so check what’s in the $69 tier before committing. Supabase is the only database option. Limited auth compared to bigger kits.

Verdict: The second-best CMS story in this market, and at $69 the cheapest path to a real headless CMS workflow. Best for content-heavy products (blogs, knowledge bases, documentation) where structured content matters more than landing-page flexibility.

3. Nextbase — Tier 3+ (MDX Blog + Content Features)

Price: $99 (Essential) / $299 (Pro) / $399 (Ultimate) — one-time, lifetime access
URL: usenextbase.com
CMS type: MDX blog + roadmap, changelog, user feedback (Pro tier and up)

Nextbase ships an MDX blog like everyone else in tier 3, but the Pro tier ($299) adds content features that most starter kits don’t: a user feedback collection system, a public roadmap, and a changelog. None of these are a CMS in the strict sense, but they’re content features you’d otherwise build yourself or pay separate tools for (Canny, Featurebase, etc.).

What a non-developer can do on day one:

  • Read user feedback and roadmap submissions in the admin panel
  • Publish changelog entries (depending on implementation)
  • Not much beyond that — blog content still lives in MDX

The rest of the stack: Next.js 15+, TypeScript, Supabase, Stripe, Tailwind CSS + Shadcn, Jest + Playwright, Sentry, PostHog + Google Analytics, OpenAI GPT-4. The monitoring stack (Sentry + PostHog) is a real differentiator — you start with observability instead of bolting it on later.

Trade-offs: The blog is still MDX. Features are tier-locked, so the content extras only show up at $299 Pro and above. Supabase only. Smaller community than ShipFast or Makerkit.

Verdict: Worth considering if you want product-content features (feedback, roadmap, changelog) bundled in. Not a CMS replacement, but the closest thing to one in the tier 3 group.

4. ShipFast — Tier 3 (MDX Blog)

Price: $199 (Starter) / $249 (All-in) / $299 (Bundle) — one-time
URL: shipfa.st
CMS type: MDX blog

ShipFast is the most popular Next.js boilerplate by community size, but the CMS story is minimal: an MDX blog. That’s it. No admin UI for content, no headless CMS integration, no page builder. ShipFast is built around the philosophy of shipping fast — content management is something you add later if you need it.

The rest of the stack: Next.js 15, TypeScript or JavaScript, MongoDB or Supabase, Google OAuth + magic links, Stripe + LemonSqueezy, Tailwind CSS, Mailgun or Resend. The 8,000+ Discord and shipping culture are the real product — the codebase itself is lighter than most competitors at the same price.

Trade-offs: Beyond the lack of CMS, there’s no admin dashboard beyond basic user management, no analytics, no AI features. If your SaaS grows past MVP, you’ll build a lot on top of what ShipFast ships.

Verdict: Best for founders who want to ship an MVP in a week and don’t care about a CMS workflow until later. If you do care about CMS, look elsewhere.

5. LaunchFast — Tier 3 (MDX Blog)

Price: $99 (single framework) / $149 (Astro + Next.js + SvelteKit bundle) — one-time
URL: launchfa.st
CMS type: MDX blog

LaunchFast is the “bring your own stack” kit — it supports 5+ database providers, multiple email services, several storage backends, and deploys to half a dozen targets. The trade-off is that the kit itself is less opinionated, which extends to content: you get an MDX blog and the assumption that you’ll wire up whatever CMS you want on top.

The rest of the stack: Next.js (also Astro and SvelteKit), TypeScript, MongoDB/Firebase/PostgreSQL/Redis/SQLite, flexible auth, Stripe + LemonSqueezy, Resend/Postmark/SendGrid/Mailgun/AutoSend, S3/R2/Firebase/Supabase Storage.

Trade-offs: Maximum flexibility, but more decisions and more wiring. If you want a CMS, you’re integrating one yourself.

Verdict: Good if you already know you want a specific CMS (Payload, Strapi, Sanity, Directus) and want a kit that won’t fight you when you add it. Not good if you want a CMS to be solved for you.

6. Shipped.club — Tier 3 (MDX Blog + Special Pages)

Price: $149 — one-time, lifetime access
URL: shipped.club
CMS type: MDX blog + waitlist / pre-order / affiliate pages

Shipped.club ships an MDX blog plus pre-built pages most kits don’t include: a waitlist page, a pre-order page, and an affiliate program page. These aren’t CMS-managed (they’re code), but they’re content surfaces that ship working out of the box.

The rest of the stack: Next.js 14, TypeScript, Supabase, NextAuth + Supabase Auth, LemonSqueezy (no Stripe), ChakraUI + Tailwind, MailChimp + Loops. PPP pricing is a nice touch.

Trade-offs: Stuck on Next.js 14. LemonSqueezy is the only payment option. Supabase only. Less documentation than the bigger kits.

Verdict: Worth considering if the bundled pages (waitlist, pre-order, affiliate) save you time and an MDX blog is enough on the content side.

Kits without any CMS at all

For honesty: four kits in the broader Next.js SaaS market ship with no CMS layer of any kind. Worth mentioning so this list doesn’t look cherry-picked.

  • Supastarter ($349+): excellent on payments (5 providers), team management, and multi-framework support (Next.js / Nuxt / SvelteKit), but blog and docs only — no CMS.
  • Makerkit ($299+): strong on multi-tenant B2B with row-level security and the most complete auth on the market, but no CMS.
  • Nextless.js ($699+): AWS-native with serverless and React Native support, but no CMS.
  • Vercel SaaS Starter (free): minimal by design — auth, Stripe, Drizzle. You’d add a CMS yourself.

These are good kits for what they do. They just don’t compete on content management.

CMS-Focused Comparison Table

Kit Price CMS Type Non-Dev Editing Blog Visual Builder Media Library
RapidLaunch $69 Drag-and-drop builder Yes Via CMS Yes (20+ sections) Yes
Indie Starter $69 Sanity CMS Yes (Sanity Studio) Sanity No Yes (Sanity)
Nextbase $99 / $299 Pro MDX + feedback/roadmap Partial (admin only) MDX (Pro) No No
ShipFast $199 MDX No Yes No No
LaunchFast $99 MDX No Yes No No
Shipped.club $149 MDX + special pages No Yes No No

How to Choose

Choose RapidLaunch if you need a non-developer to own the marketing site, or you don’t want to redeploy every time a headline changes. The drag-and-drop builder is the only one of its kind in this market, and at $69 one-time it undercuts two months of a Webflow CMS subscription.

Choose Indie Starter if you want a real headless CMS workflow (Sanity Studio) at the lowest possible entry price ($69). Best for content-heavy products where structured content matters more than visual page composition.

Choose Nextbase Pro if you want product-content features (user feedback, roadmap, changelog) bundled with your starter, and you’re okay with MDX for the blog itself.

Choose ShipFast, LaunchFast, or Shipped.club if an MDX blog is genuinely enough for now and you’ll handle content management yourself later. These are good kits — just don’t pick them expecting a CMS workflow.

Final Thoughts

The CMS gap in the Next.js SaaS starter market is real. Most kits hand you authentication, payments, and a landing page, then leave content management as an exercise for the reader. For solo developers building their first product, that’s often fine. For anyone with a non-technical co-founder, a marketing function, or a growth-stage product where copy changes weekly, it’s a problem you’ll hit by month two.

If you want a CMS workflow without paying extra for one or building it yourself, the practical choice today is RapidLaunch for a full visual page builder, or Indie Starter for an integrated Sanity workflow at the lowest price. Everything else in this market punts on the content layer.

If I were starting a new SaaS today and knew I’d be editing the marketing site regularly, I’d pick RapidLaunch. The combination of a drag-and-drop builder, an AI template generator, and the rest of the kit (auth, payments, admin dashboard, analytics) at $69–$119 one-time is a better deal than buying Webflow plus any other starter on this list.

Try RapidLaunch and run your marketing site without a deploy pipeline.

How Gemma 4’s Per-Layer Embeddings Actually Work — And Why E2B Punches Above 2B

How Gemma 4’s Per-Layer Embeddings Actually Work — And Why E2B Punches Above 2B

This is a submission for the Gemma 4 Challenge: Write About Gemma 4

Every benchmark article about Gemma 4 E2B says roughly the same thing: “Despite being a 2B model, it outperforms older 7B models on reasoning tasks.” Then they move on. Nobody explains why. Nobody opens the hood.

The answer is a mechanism called Per-Layer Embeddings (PLE) — and once you understand it, you’ll never read an LLM parameter count the same way again.

This article breaks down the exact mechanism, walks through it with a concrete analogy, compares E2B’s real benchmark numbers against similarly-sized rivals, and explains what PLE means for quantization and deployment.

The Problem PLE Was Built to Solve

To understand PLE, you first need to understand the problem it solves in standard transformers.

In a vanilla transformer (GPT-2, Llama 3, early Gemma generations), every token gets one embedding vector at the input layer. That vector is a fixed lookup from a table: token ID 4821 → a 4096-dimensional float vector. The model then passes this vector through 32 (or 64, or 80) layers of attention and feed-forward networks. The original embedding never refreshes — it just flows forward as a residual signal.

Here’s the constraint this creates. Imagine you’re writing a mystery novel and you introduce the word “bank” on page 1. The model has to encode — in that single input embedding — everything that word might mean across the entire 128,000-token context:

  • Is it a riverbank?
  • A financial institution?
  • Is it a verb (“bank the aircraft”)?
  • Is the protagonist going to rob it on page 80?

The embedding vector has to carry all this ambiguity, and each layer has to do the hard work of resolving it through attention alone. For large models with billions of parameters spread across 80 layers, attention has enough capacity to do this. For a small 2B model with only 18–26 layers, the pressure on that single input embedding is enormous. The model either oversimplifies meaning early, or spends most of its capacity just resolving token ambiguity instead of reasoning.

This is the “bottleneck” that stunts small models. And it’s exactly what PLE dismantles.

What Per-Layer Embeddings Actually Do

The idea, when you see it plainly, is almost obvious.

Instead of one embedding at the input, give every layer its own embedding signal for every token.

Here’s what that looks like architecturally. Alongside the standard embedding table, the E2B model has a second embedding table — embed_tokens_per_layer — that produces a packed vector for every token. That packed vector is then sliced into 35 pieces (one per decoder layer). Each slice is a small, low-dimensional vector (~305 dimensions) that gets injected into its corresponding layer as an additional residual signal.

In pseudocode pulled directly from the Hugging Face Transformers implementation:

# Standard embedding (unchanged)
hidden_states = embed_tokens(input_ids)  # shape: [batch, seq, 2304]

# PLE: one big lookup, then slice per layer
ple_packed = embed_tokens_per_layer(input_ids)
# shape: [batch, seq, num_layers * per_layer_dim]
# → reshaped to [batch, seq, 35, 305]

# At each decoder layer i:
layer_signal = ple_packed[:, :, i, :]   # [batch, seq, 305]
# Combined with context-aware projection, scaled by 1/√2
# Injected into the residual stream AFTER attention + FFN
hidden_states = hidden_states + gate(layer_signal)

The key mechanics here, each worth unpacking:

1. The signal is gated, not blindly added.
The injection uses a learned gate — the current hidden state decides how much of the per-layer token signal to absorb. If layer 18 has already resolved the ambiguity of “bank” from context, the gate suppresses the token-identity refresh. If it’s still confused, the gate opens. This is adaptive, not mechanical.

2. It combines two components.
Each per-layer vector is a sum of:

  • A token-identity component — what this token is, looked up from the PLE table
  • A context-aware component — a learned projection of the main embedding, capturing what the token means given what came before

These two signals are summed and scaled by 1/√2 (normalizing their combined magnitude) before injection. The model learns to balance raw token identity with contextual interpretation at every depth.

3. The compute cost is almost nothing.
The PLE table is just an array lookup — table[token_id]. No matrix multiplication. No attention. The entire 35-layer lookup for a token takes microseconds and runs once at the start of inference, not once per layer. This is why PLE is so efficient: it adds representational richness with almost zero inference overhead.

A Personal Example: The Word “Fine”

Let me make this concrete with a short passage:

“The weather is fine today. The fine for speeding is ₹2,000. I feel fine about it, honestly.”

In a standard small transformer, the token "fine" receives the same initial embedding vector all three times. That vector has to somehow encode noun-fine (penalty), adjective-fine (weather quality), and verb-phrase-fine (emotional state) simultaneously. The model’s shallow layer count means it may never fully disambiguate all three — leading to reasoning errors downstream.

With PLE, here’s what happens differently:

  • At layer 5 (early syntactic processing), the PLE signal for "fine" reinforces its token identity — “this is an ambiguous adjective/noun.” The gate is open because the context hasn’t resolved it yet.

  • At layer 14 (mid-depth semantic processing), the model has seen “speeding” and “₹2,000” in the context window. The context-aware component of the PLE signal now weights toward the noun/penalty sense. The hidden state updates accordingly.

  • At layer 26 (deep reasoning), the phrase “I feel fine about it” provides strong cues. The PLE signal now reinforces the emotional state reading. The gate opens again for this instance while suppressing it for the others.

Each occurrence of “fine” gets its own resolved representation at depth — not because attention alone did the work, but because the per-layer signal kept refreshing the token’s identity throughout the network. The model doesn’t have to “remember” ambiguity resolution from layer 5 all the way to layer 26.

For a 2B model, this is the difference between reasoning correctly and reasoning confidently but wrongly.

The Numbers: What This Actually Unlocks

Here’s where PLE stops being theoretical and starts being remarkable. These are verified benchmark figures for Gemma 4 E2B (2.3B effective parameters):

Benchmark Gemma 4 E2B Phi-3 Mini (3.8B) Llama 3.2 3B What it tests
AIME 2026 (math) competitive edge model not published not tested Hard math olympiad
MMLU Pro outperforms class ~53% ~45% Graduate-level knowledge
Context window 128K tokens 128K 128K Long-context recall
Active parameters 2.3B 3.8B 3B Inference cost
Total parameters 5.1B 3.8B 3B Storage weight
RAM (Q4 quantized) ~1.5 GB ~2.4 GB ~2.0 GB Device requirement
Multimodal inputs Text, image, audio Text only Text only Input modalities

The headline number that matters: E2B carries 5.1B total parameters but runs at 2.3B inference cost. The gap between those two numbers — 2.8B parameters — is almost entirely the PLE embedding tables.

Storage is cheap. Compute is expensive. PLE exploits this asymmetry precisely.

For a larger-scale reference: the Gemma 4 31B (no PLE, standard architecture) scores 85.2% on MMLU Pro and 89.2% on AIME 2026. Even the tiny E4B (4.5B effective) hits 42.5% on AIME — more than double what Gemma 3 27B managed (20.8%) on the same benchmark. PLE does not fully explain this jump (architecture improvements, training data quality, and reasoning mode all contribute), but it is the mechanism that makes the efficiency ratio possible.

How E2B Compares to Other Small Models

It helps to see E2B in context against the current small-model landscape:

vs. Phi-4 Mini / Phi-3 Mini (Microsoft)

Phi models achieve strong reasoning through data curation — training on extremely high-quality synthetic reasoning chains. They don’t use PLE. The result is impressive per-parameter quality, but Phi-4 (3.8B) requires more RAM than E2B, lacks native multimodal support, and has no audio input. For edge deployment on a phone or IoT device, E2B wins on memory footprint and modality breadth.

vs. Qwen 3.5 0.8B / 1.8B (Alibaba)

Qwen’s small models use standard embeddings with a larger vocabulary (151,936 tokens). They’re competitive on multilingual text tasks. However, they lack audio input and their context window at the smallest tier is shorter. PLE’s per-layer disambiguation gives E2B an edge on long-context reasoning tasks where token ambiguity compounds over distance.

vs. Llama 3.2 3B (Meta)

Llama 3.2 3B is a fine model for its size but uses standard transformer architecture with no equivalent of PLE. It requires more RAM than E2B for similar quality, lacks multimodal input, and was trained primarily on text. For developers targeting mobile or edge, Llama has no models at this tier at all — Llama 4’s smallest model (Scout) requires approximately 70 GB of VRAM.

vs. Gemma 3 2B (previous generation)

This is the cleanest comparison because it isolates the architectural change. Gemma 3 2B and Gemma 4 E2B are both Google DeepMind models, similar training compute, similar target hardware. The introduction of PLE (plus the vocabulary expansion to 262,144 tokens, supporting 140+ languages) is the primary architectural delta. The quality jump is substantial — E2B handles multimodal input, produces richer reasoning chains, and degrades more gracefully on complex tasks.

What PLE Means for Quantization

This is the part that most articles skip, and it matters for anyone deploying Gemma 4 on constrained hardware.

The parameter split matters

When you quantize a standard LLM to 4-bit (Q4), you’re applying precision reduction uniformly across all parameters — attention weights, FFN weights, and embeddings all get the same treatment. With E2B’s PLE architecture, the 2.8B PLE parameters and the 2.3B compute parameters have different sensitivity profiles.

The PLE embedding tables are just lookup arrays — integer indices map to float vectors. They are:

  • Less sensitive to quantization than attention weights (no gradient flows through them during inference; they’re read-only lookups)
  • Compressible to lower bit-widths without significant quality degradation
  • Dominant in the model’s file size (2.8B out of 5.1B total parameters)

This means you can apply asymmetric quantization: aggressively quantize the PLE tables (say, to 2-bit or 3-bit) while keeping the 2.3B compute parameters at 4-bit or 8-bit precision. The result: smaller file on disk, faster memory loading, minimal quality drop.

Practical RAM targets (approximate, Q4 build)

Quantization File size RAM needed Best for
Q8 (8-bit) ~5.5 GB ~6 GB Laptops, desktop GPUs
Q4 (4-bit) ~2.8 GB ~3.5 GB Standard Android phones
Q2 (aggressive) ~1.6 GB ~2.0 GB Budget phones, Raspberry Pi 5
INT4 mixed (PLE Q2 + compute Q4) ~1.8 GB ~2.3 GB Embedded systems

The officially stated target of 1.5 GB RAM for E2B refers to an optimized mixed-precision build using LiteRT-LM (Google’s edge inference framework). Standard GGUF Q4 builds on Ollama will use slightly more.

Implication for fine-tuning

When fine-tuning E2B (LoRA, QLoRA), the standard practice is to skip embedding layers and focus adapters on attention and FFN weights. With PLE, there’s a decision to make: fine-tune the PLE tables or freeze them?

The PLE tables encode general token identity at every layer depth — they’re relatively task-agnostic. Freezing them during fine-tuning saves significant VRAM (2.8B frozen parameters) with minimal quality cost for most downstream tasks. Only if your task involves a highly specialized vocabulary (medical jargon, a low-resource language, domain-specific notation) would fine-tuning the PLE tables likely help.

The Bigger Picture: What “Effective Parameters” Really Means

The “E” in E2B and E4B stands for Effective — and PLE is precisely why that distinction exists.

The industry has been obsessed with parameter counts as a proxy for model quality since GPT-3. PLE breaks that proxy in a specific, instructive way. A model can have:

  • High total parameters → large storage footprint, expensive to download
  • Low active (compute) parameters → fast inference, low energy use
  • High representational richness → quality beyond what active-parameter count predicts

These three properties were previously coupled. Mixture-of-Experts (the other 2026 technique) decouples them at the FFN level — only some experts activate per token. PLE decouples them at the embedding level — only the cheap lookup runs per token, not the expensive projection.

Together, MoE and PLE represent a generational shift in how model architects think about efficiency. The question is no longer “how many parameters does the model have?” The correct question is: “how many parameters does the model use on any given forward pass, and what do the rest contribute?”

E2B’s answer: 2.3B parameters do the compute work. 2.8B parameters sit in lookup tables and provide layer-specific context signals at almost zero cost. The result is a model that fits in your pocket — literally, on a Pixel phone — while reasoning at a level that would have required a 7B model a generation ago.

Summary: The Mechanism

  1. Standard transformers give each token one embedding at the input. Small models suffer because that single vector carries semantic ambiguity through all layers.

  2. PLE adds a second embedding table. For each token, it produces a small dedicated vector for every decoder layer — pre-computed in a single lookup, sliced and injected per layer.

  3. Each per-layer signal combines token identity with a context-aware component, gated by the current hidden state. The model learns when to refresh token meaning and when it’s already resolved.

  4. The PLE tables (2.8B params) are almost free at inference time. They’re memory, not compute. This is why E2B stores 5.1B parameters but runs like 2.3B — and punches well above both.

References and Further Reading

If you found this useful, I’d love to hear what you’re building with E2B in the comments. Are you running it on a phone, an edge device, or something more exotic? The constraint-driven use cases are the interesting ones.

Transactions Without Leaking the Database into Your Domain

A use case wrapped in a transactional decorator at the composition root

A PlaceOrder use case is sitting on your screen. It writes the order. It reserves stock. It records a ledger entry. Three repositories, three writes, one business intent. If the ledger write fails, the order and the reservation have to vanish with it. Anything less, and a customer ends up with stock held against an order that does not exist.

You know the word for what you need. Atomicity. The database has known it for fifty years. So you reach for the obvious tool.

public function execute(PlaceOrderInput $input): PlaceOrderOutput
{
    return $this->entityManager->wrapInTransaction(
        function () use ($input) {
            $order = Order::place(/* ... */);
            $this->orders->save($order);
            $this->reservations->reserve(/* ... */);
            $this->ledger->record(/* ... */);
            return new PlaceOrderOutput($order->id->value);
        }
    );
}

It works. Tests go green, code review nods it through, you move on to the next ticket.

You also just made a mistake. The use case now imports DoctrineORMEntityManagerInterface. The application layer, the part you were supposed to keep ignorant of the database, has Doctrine in its constructor. Every other use case will copy the pattern, because copy-paste is how patterns spread. Three months from now the application has dozens of references to EntityManager and one of your engineers is asking if you can move a slice of the system to MongoDB.

You cannot. Not cheaply.

This post is about getting atomicity without paying that price.

A use case wrapped in a transactional decorator at the composition root

What the problem actually is

Transactions are an infrastructure concept. They live in the database, the driver, the ORM. The application layer needs the guarantee (all writes succeed together or none do) but it does not need the mechanism. The Dependency Rule says outer rings depend inward; inner rings know nothing of the outer. A PlaceOrder that calls EntityManager::wrapInTransaction inverts that. The use case is reaching outward into Doctrine.

The cost shows up first in testability. Every test for PlaceOrder now needs a real or mocked EntityManager. The unit tests with in-memory repositories and no database at all stop being unit tests. They become integration tests with extra steps.

It also shows up in portability. The word makes the problem sound grander than it is. “Switch ORMs” reads like something only architects in slide decks worry about. The real version is smaller. You want to run a use case from a CLI script against raw DBAL. You want a worker process that wires Doctrine differently. You want a test harness that runs the use case against an in-memory state machine. All of those break when EntityManager is welded to the use case.

You want the guarantee without the import.

Option 1: a UnitOfWork port

You write an interface in the application layer that expresses the transaction boundary in your own vocabulary. The implementation lives at the edge.

<?php

declare(strict_types=1);

namespace AppApplicationPort;

interface UnitOfWork
{
    /**
     * @template T
     * @param callable(): T $work
     * @return T
     */
    public function wrap(callable $work): mixed;
}

One method. The contract is plain: run the callable, and if it returns, the writes are durable; if it throws, the writes are undone and the exception propagates.

Notice what is missing. No begin, no commit, no rollback. Those exist in the adapter. The application never sees them, because there is no reasonable case for application code to start a transaction without also being responsible for ending it. You can construct hypotheticals, but they tend to dissolve back into a design smell once you look closely.

The Doctrine adapter is six lines.

<?php

declare(strict_types=1);

namespace AppInfrastructurePersistenceDoctrine;

use AppApplicationPortUnitOfWork;
use DoctrineORMEntityManagerInterface;

final readonly class DoctrineUnitOfWork implements UnitOfWork
{
    public function __construct(
        private EntityManagerInterface $em,
    ) {}

    public function wrap(callable $work): mixed
    {
        return $this->em->wrapInTransaction(
            static fn () => $work(),
        );
    }
}

wrapInTransaction is the Doctrine 3 successor to the deprecated transactional. It opens a transaction on the underlying DBAL connection, runs the callable, flushes the EntityManager, commits on clean return, and on exception rolls back, closes the EntityManager, and re-throws the original error.

The Laravel adapter is also six lines (it calls DB::transaction($work)). The Symfony version wraps a Connection::transactional. You can write a PDO-only version that calls beginTransaction, commit, rollBack by hand. Same port, four different adapters, zero changes in the application layer.

The downside: you wrote and now maintain a port. One more file, one more abstraction in the stack trace.

The upside is bigger than it looks. The port composes with a decorator, and that is where the real win lives.

Option 2: a transactional decorator at the wiring level

The use case stays exactly as it was on the day you wrote it, before you knew transactions existed. It takes its repositories. It calls them. It returns. The transaction is composed at the boundary.

<?php

declare(strict_types=1);

namespace AppApplicationDecorator;

use AppApplicationPortUnitOfWork;
use AppApplicationUseCase;

final readonly class TransactionalDecorator implements UseCase
{
    public function __construct(
        private UseCase $inner,
        private UnitOfWork $unitOfWork,
    ) {}

    public function execute(mixed $input): mixed
    {
        return $this->unitOfWork->wrap(
            fn () => $this->inner->execute($input),
        );
    }
}

That is the whole class. It is the decorator pattern from the Gang of Four book, applied to use cases. The decorator implements the same interface as the thing it wraps, holds an instance of that thing, and adds behavior around the inner call. Here the added behavior is a transaction.

In the container, you bind PlaceOrder::class to a factory that builds the inner PlaceOrder and wraps it.

$inner = new PlaceOrder($orders, $reservations, $ledger);
$useCase = new TransactionalDecorator(
    $inner,
    $unitOfWork,
);

Callers ask the container for a PlaceOrder and get a decorated one. They do not know. They should not care. The HTTP controller, the CLI command, the queue worker all receive the same wrapped instance.

This is the option to reach for first, mostly because of what it prevents. PlaceOrder has no UnitOfWork in its constructor, so it cannot accidentally call begin and forget to call commit. It cannot half-commit a transaction by catching an exception it should not have caught. The boundary is enforced by composition, not by discipline (which, given enough engineers and enough deadlines, always loses).

Decoration also composes. You can stack a LoggingDecorator, a MetricsDecorator, a RetryDecorator, and a TransactionalDecorator in whatever order makes sense:

$inner       = new PlaceOrder($orders, $reservations, $ledger);
$transacted  = new TransactionalDecorator($inner, $unitOfWork);
$retried     = new RetryDecorator($transacted, maxAttempts: 3);
$metered     = new MetricsDecorator($retried, $metrics);
$logged      = new LoggingDecorator($metered, $logger);

Logging usually wraps the whole thing because you want to see the call even when the transaction rolled back. Retries wrap the transactional decorator so each retry is its own transaction. Metrics record either side depending on what you want to measure. The use case stays one class with one responsibility, and the cross-cutting concerns live as siblings around it.

Where the pattern frays: long-running operations (a six-second report, an external API call that takes its time) should not be inside a database transaction, so they should not be decorated. Distributed transactions across two databases are not what the decorator gives you; it wraps one connection.

Those cases need different machinery. For everything else, the port-plus-decorator pair is the answer.

Option 3: the pragmatic one — let the application know Doctrine flushes

You see this in production Laravel and Symfony codebases that have not yet adopted hexagonal architecture, or have adopted it partially. The use case calls $this->entityManager->flush() at the end, and a higher-level controller or middleware opens the transaction.

public function execute(PlaceOrderInput $input): PlaceOrderOutput
{
    $order = Order::place(/* ... */);
    $this->orders->save($order);
    $this->reservations->reserve(/* ... */);
    $this->ledger->record(/* ... */);
    $this->entityManager->flush();

    return new PlaceOrderOutput($order->id->value);
}

This works. Doctrine batches the writes until flush. If you opened a transaction at the controller level (or relied on wrapInTransaction somewhere above), the flush either commits or rolls back as one unit. You skipped writing a port. You have one less interface to teach to new hires.

The cost is that “the application calls flush” is now part of the contract. flush is a Doctrine method. Your application layer imports EntityManagerInterface. You traded a port for a leak. If you ever swap Doctrine out (a CQRS write side that uses raw DBAL, a different ORM, a test harness) every use case that calls flush is now framework-coupled and has to change.

For a single-team service that will live and die on Doctrine, this approach is fine. For a service that you expect to outlive its current persistence stack, it is debt that you will pay later.

It is included here because pretending it does not exist would misrepresent what runs in production. Plenty of working systems use it. Use it with eyes open.

Rollback flow when an exception propagates through the decorator

Rollback semantics worth being precise about

When the adapter rolls back, here is what actually happens.

The database transaction reverses. Every INSERT, UPDATE, and DELETE issued inside the wrapped callable is gone as if it never happened.

The EntityManager is closed. Doctrine’s identity map is in a known-bad state after a rollback (it may still hold managed entities that no longer exist in the database) so the ORM defensively shuts the EntityManager. If you wire the EntityManager as a long-lived singleton, handle reset between requests, or use a factory that returns a fresh instance after each rollback. Most modern frameworks handle this automatically when EM lifetime is scoped per request.

The exception keeps its identity. A DomainException thrown from the inner use case arrives at the outer caller as the same DomainException, not wrapped, not transformed. Domain exceptions cross the boundary unchanged so the HTTP adapter can translate them properly. The UnitOfWork port does not catch and rethrow, it does not wrap; it lets the exception go past it on the way out.

The application code at the controller level sees a clean picture. Either $useCase->execute($input) returned a value, in which case the writes are committed, or it threw, in which case they are not. No “partially committed,” no “succeeded but didn’t flush.” That binary is the guarantee you were after when you started reaching for transactions in the first place.

Nested transactions: do not

You will eventually find yourself with a use case that calls another use case. Both are wrapped in TransactionalDecorator. What happens?

The Doctrine answer is technically savepoints. wrapInTransaction detects an open transaction on the underlying connection and uses a savepoint for the nested call. The outer transaction owns the real commit. The inner savepoint can roll back independently.

The practical answer: do not rely on this. Savepoints are not free; they generate extra round-trips and locks. Their semantics across database engines are not uniform. Postgres behaves differently from MySQL, which behaves differently from SQLite. Your test infrastructure may not reproduce production behavior. And worse, nested transactional use cases usually mean you have a composition problem: an “orchestrator” use case that should probably be split, or a use case calling another use case directly when it should be emitting an event.

One transaction boundary per inbound call. The controller invokes one decorated use case. That use case may internally call domain services, may emit domain events, may write to multiple repositories, but it stays inside the one transaction the decorator opened. If you need cross-aggregate work that cannot fit, you do not nest. You decompose, and the second operation runs in its own transaction, triggered by an event.

A reflection test that keeps the boundary honest

Architectural rules you do not enforce in code tend to drift, and the drift is invisible until you trip over it during a refactor. The smallest possible enforcement for “the application layer does not know about transactions” is a reflection test.

<?php

declare(strict_types=1);

namespace TestsArchitecture;

use AppApplicationUseCasePlaceOrderPlaceOrder;
use PHPUnitFrameworkTestCase;
use ReflectionClass;

final class PlaceOrderIsTransactionAgnosticTest extends TestCase
{
    public function test_does_not_depend_on_transaction_machinery(): void
    {
        $forbidden = [
            'EntityManager',
            'wrapInTransaction',
            'beginTransaction',
            '->flush(',
            'commit(',
            'rollBack',
        ];

        $source = file_get_contents(
            (new ReflectionClass(PlaceOrder::class))
                ->getFileName(),
        );

        foreach ($forbidden as $token) {
            self::assertStringNotContainsString(
                $token,
                $source,
                sprintf(
                    'PlaceOrder leaked persistence concern: %s',
                    $token,
                ),
            );
        }
    }
}

The next engineer who reaches for the obvious-but-wrong solution sees this test fail in CI before the pull request gets a human reviewer. Steal it. Adapt it. Add similar guards for the other invariants you care about.

The honest trade-off

The decorator-plus-port approach is what most production codebases that have lived through a framework migration end up with, but it is not free.

The bill: one extra class per cross-cutting concern (the decorator), one extra interface for the port, plus DI configuration to bind them together. Every framework has its own dialect (Symfony’s decoration tags, Laravel’s app->bind callbacks, raw containers’ factories). Stack traces from a wrapped use case include the decorator frame. That makes debugging marginally noisier. New engineers need to be told why a class named PlaceOrder is actually a TransactionalDecorator at runtime, because their IDE does not show that.

The pragmatic approach is faster to write and easier to explain on day one. $em->flush() at the bottom of every use case is two words. Anyone who knows Doctrine reads it instantly. No port, no decorator, no DI gymnastics. The cost is invisible until the day you want to evolve away from Doctrine, and by then the cost is dozens of files.

The middle ground (the port without the decorator) exists but is weakly motivated. If the port is already written, the decorator is free. Spreading $this->unitOfWork->wrap(...) calls across every use case when one decorator handles it once gains nothing. The only situation where the bare port makes sense is when some use cases need to not be transactional (long-running reports, idempotent reads), in which case you decorate the ones that need it and leave the rest alone.

Pick based on the lifespan you expect from the application. Code that will exist for three years and then be replaced: pragmatic. Code that you expect to keep running for a decade through framework upgrades and ORM migrations: port plus decorator.

What the use case never knew

Step back and notice what you built.

PlaceOrder is forty-something lines. It takes three repositories, calls them, returns. No transaction code, no ORM imports, no awareness of when a flush happens or what driver underpins the storage.

It is also fully atomic in production, fully testable without a database, and free to be wired into a CLI script, a queue worker, an HTTP controller, or a test harness with no changes. The behavior you wanted (all writes succeed together or none do) sits one layer above the class. That layer is the decorator. The decorator delegates to a port. The port has two implementations: one for Doctrine, one for tests.

If next year you replace Doctrine with raw DBAL, you write a new adapter and PlaceOrder does not change. Add MongoDB for some aggregates the year after, and you write a third adapter that holds a session and uses Mongo’s transactional API; the use case still does not notice.

Transactions feel inherently database-y, which is why people stop trying to abstract them. The abstraction is small: a port, an adapter, and a decorator. You can have atomicity and decoupling at the same time. The cost is three files.

If this was useful

This is one chapter from Decoupled PHP — the book about treating Laravel or Symfony as an adapter and writing the rest of your application against ports it owns. The transaction chapter pairs with a chapter on the outbox pattern for the cases where one transaction is not enough, and a chapter on testing the hexagon without booting a container. If you want the database side of the story (picking the right store, designing schemas that survive growth, the actual trade-offs across Postgres, MySQL, MongoDB, DynamoDB) the Database Playbook is the companion volume.

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.

Introduction

Hi everyone! 👋

My name is Fahad and I’m a frontend web developer currently learning and building modern responsive websites using HTML, CSS, JavaScript, PHP, and MySQL.

I recently started working on:

  • E-commerce websites
  • Landing pages
  • Dashboard UIs
  • Expense tracker projects

I joined DEV to share my learning journey, projects, and connect with other developers. 🚀

Looking forward to learning from this amazing community!

Toward a more POSIX-Friendly PowerShell experience

I’m a MacOS and Debian user primarily, but my 9-5 is a Windows shop that requires I work from a Windows PC primarily due to security constraints. I also work in the public sector, which means that my hardware is slow and outdated because that’s what our taxpayer-allotted budget provides for.

Being stuck on an underpowered laptop, I can’t really run Windows Subsystem for Linux without taking a significant performance hit — especially when it comes to the ever-expanding size of the WSL virtual machine image that eats up my available disk space without constant pruning. I therefore have opted to forgo WSL and work directly in Windows, using PowerShell as my primary command-line environment.

As someone who spends a significant amount of time in Z shell (ZSH) working with coding agents, it can be fairly annoying having to “code switch” between my Mac and PC every day, especially when trying to get an agent to understand the different context. So, over the years, I have adopted these useful tricks to help add some parity between the Windows and POSIX (UNIX-compatible) experiences.

1. Install a better terminal app

Windows comes bundled with a PowerShell-specific terminal emulator, but it is non-customizable and frankly not worth your time. The fastest and easiest improvement you can make upfront is switching to Windows Terminal, which renders a bit slowly but is very customizable and far superior to most other terminal options.

If you’re doing agentic coding, however, you may want to opt for an AI-integrated multiplexing shell. On the Mac, I use cmux for this. On the PC, Wave is the closest quality alternative I’ve found, which is my overall recommendation.

2. Install all the package managers

There are now three package managers available for Windows, each of which has its own strong suit and all of which are necessary to access the breadth of available Windows software across the web. None of these tools individually is sufficient, and therefore all three are required.

  • Winget is Microsoft’s official package manager for Windows which provides command-line access to install/uninstall products from the Microsoft Store and the Windows package registry.
  • Chocolatey is a commercial package manager, predating Winget, with a community edition that offers access to loads of commercial and open-source Windows software with its own registry.
  • Scoop is an open-source package manager that offers Windows-versions of popular cross-platform CLI and TUI tools.

3. Install Windows-friendly POSIX command-line utilities

These are, IMO, the absolute non-negotiables:

  • cygwin – Windows ports of essential POSIX command-line utilities, like head, tail, wc, wget, and others we generally take for granted that simply do not exist in Windowsland.
  • fzf – Fuzzy Finder is like a command-line version of Everything, an essential Windows search tool.
  • gpg4win – GPG CLI and GUI tools for encryption/decryption and key signing commits.
  • gsudo – sudo for Windows. One of the more annoying aspects of working in Windows is that if you’re in a terminal shell and you need to run root-like commands, you need to reopen the terminal as an Administrator user or the command will fail. gsudo simplifies this process by allowing you to throw it in front of any command, which will then open it in an admin window. Microsoft recently added its own sudo command, which you can enable through the Developer settings pane, but I find that it works far less consistently than gsudo.
  • git – If this isn’t already installed, what are you even doing?
  • Git GCM – A Git helper utility that manages authentication between Git and third-party repository hosts like GitHub.
  • podman-desktop – Open-source Docker alternative. Even in choosing to forgo WSL as one’s primary development environment, the ability to run Docker containers is still essential in today’s day and age. I recommend installing Podman, the free alternative to Docker developed by RedHat, which will set up a small Linux machine image in which to run your Docker containers.
  • ripgrep – A fancier version of grep commonly used by coding tools.

These are the coding frameworks you’ll likely want to install, especially if you want to install any third-party utilities from their package ecosystems:

  • golang – The Go language and compiler.
  • nodejs-lts – The LTS version of Node.js.
  • pnpm – The Performant Node Package Manager. Helps save disk space when working with Node packages.
  • rust – The Rust language and compiler.
  • uv – The best version manager for Python.

These are nice-to-haves to improve your user experience and make your shell a bit more comfortable to work with:

  • atuin – The best shell history manager that ever was.
  • cross-env – Allows you to execute commands consistently across Windows and POSIX environments. Especially useful when you want to pass environment variables to a command without having to go through PowerShell’s more onerous syntax for setting variables.
  • fnm – Fast Node Manager, a more performant Node version manager.
  • fresh – A modern, user-friendly TUI code editor.
  • lsd – A fancy dir replacement that you can alias to ls.
  • starship – The only terminal prompt utility you’ll ever need. Make your command-line prompt consistent no matter the OS.
  • topgrade – All-in-one package updater. Works with choco, scoop, winget, and more.
  • zoxide — Jump to frequently used directories with z [partial name].

You can install everything all at once from an admin shell window with:

scoop install cygwin fnm gsudo ripgrep rust wget zoxide && 
choco install fzf git golang gpg4win lsd nodejs-lts pnpm podman-desktop starship uv whois && 
winget install -e Atuinsh.Atuin sinelaw.fresh-editor Git.GCM && 
pnpm i -g cross-env

Be sure to add Cygwin’s bin folder to your system paths, which will allow you to access its commands anywhere.

  1. Open the Windows Environment Variables editor (available from System Properties or by searching from the taskbar).
  2. Under User variables scroll down to Path and double-click it.
  3. Click New and add C:Users[your_username]scooppersistcygwinrootbin.
  4. Press ‘OK’ and keep pressing ‘OK’ until all the windows are closed.

You may have to do this for additional folders, like PNPM’s bin folder, if it’s not done automatically during installation.

4. Install PowerShell modules

Now we’re going to customize PowerShell itself to be a bit friendlier for POSIX users.

First, install PowerShellGet, which is yet another package manager, but this one is specific to PowerShell modules.

Install-Module -Name PowerShellGet -Force; exit

Then install PSReadLine, which is a tool that makes it easier to manipulate the command-line.

Install-Module PSReadLine -Repository PSGallery -Scope CurrentUser -AllowPrerelease -Force

Then enable Winget.CommandNotFound, which will suggest the correct program or other programs to install when a command you’ve run is not found. It comes default with PowerShell 7.5+.

Enable-ExperimentalFeature PSFeedbackProvider
Enable-ExperimentalFeature PSCommandNotFoundSuggestion

Finally, install Terminal-Icons, which provides NerdFont support for icons within PowerShell.

Install-Module -Name Terminal-Icons -Repository PSGallery

5. Set up Microsoft.PowerShell_profile.ps1

Now that everything’s installed, we’re going to tie it all together in a PowerShell configuration profile that loads the new utilities and maps keybindings to common POSIX shell patterns.

Your PowerShell profile, if one already exists, can be in a number of places. However, the current profile, again if it exists, should be available in the $PROFILE environment variable. Try opening $PROFILE in your preferred code editor:

fresh $PROFILE

Then insert the following:

# Aliases
Set-Alias -Name "code" -Value "C:\Program Files\Microsoft VS Code\bin\code.cmd"
Set-Alias -Name "ls" -Value "lsd"
Set-Alias -Name "nvm" -Value "fnm"
Set-Alias -Name "docker" -Value "podman"

# Import modules
Import-Module 'gsudoModule'
Import-Module -Name Terminal-Icons
Import-Module -Name Microsoft.WinGet.CommandNotFound
Import-Module PSReadLine

# Setup PSReadLine for Unix/readline-style muscle memory
Set-PSReadLineOption -EditMode Emacs
Set-PSReadLineOption -PredictionSource HistoryAndPlugin
Set-PSReadLineOption -PredictionViewStyle InlineView
Set-PSReadLineOption -HistorySearchCursorMovesToEnd

# Core editing shortcuts
Set-PSReadLineKeyHandler -Chord 'Ctrl+a' -Function BeginningOfLine
Set-PSReadLineKeyHandler -Chord 'Ctrl+e' -Function EndOfLine
Set-PSReadLineKeyHandler -Chord 'Ctrl+w' -Function BackwardKillWord
Set-PSReadLineKeyHandler -Chord 'Ctrl+u' -Function BackwardKillLine
Set-PSReadLineKeyHandler -Chord 'Ctrl+k' -Function KillLine
Set-PSReadLineKeyHandler -Chord 'Ctrl+y' -Function Yank
Set-PSReadLineKeyHandler -Chord 'Ctrl+l' -Function ClearScreen
Set-PSReadLineKeyHandler -Chord 'Ctrl+d' -Function DeleteCharOrExit

# Word movement
Set-PSReadLineKeyHandler -Chord 'Alt+b' -Function BackwardWord
Set-PSReadLineKeyHandler -Chord 'Alt+f' -Function ForwardWord

# History
Set-PSReadLineKeyHandler -Chord 'Ctrl+r' -Function ReverseSearchHistory
Set-PSReadLineKeyHandler -Chord 'Alt+.' -Function YankLastArg
Set-PSReadLineKeyHandler -Chord 'Alt+_' -Function YankLastArg

# Open the current command in the editor
Set-PSReadLineKeyHandler -Chord 'Ctrl+x,Ctrl+e' -Function ViEditVisually

# Completion behavior
Set-PSReadLineKeyHandler -Key Tab -Function Complete
Set-PSReadLineKeyHandler -Key UpArrow -Function ScrollDisplayUpLine
Set-PSReadLineKeyHandler -Key DownArrow -Function ScrollDisplayDownLine

# Init third-party utils
Invoke-Expression (& { fnm env --use-on-cd | Out-String })
Invoke-Expression (& { zoxide init powershell | Out-String })
Invoke-Expression (& { atuin init powershell | Out-String })
Invoke-Expression (& { atuin gen-completions --shell powershell | Out-String })
Invoke-Expression (& { starship init powershell | Out-String })

Relaunch your terminal and give it a spin! Now you have POSIX-style commands, autocompletion, a searchable shell history, and more. It’s definitely not a POSIX shell, nor is it a VM or an emulator. But it’s close enough to stop you and your agents from entering incorrect commands over-and-over again and to help you get your work done more quickly without bogging down your machine with needless VMs.

Got any more tips for creating parity between PowerShell and POSIX for all the code-switchers out there? Leave a comment below!

Symfony Is Also an Adapter (Yes, Even With All Its Glue)

The hexagon, with Symfony on the outside

A four-year-old Symfony codebase has a different smell than a four-year-old Laravel one. The controllers are thinner, the repositories already exist as classes, and the container is wired with attributes rather than facade calls. From a distance, the team looks like it has been doing architecture all along.

That distance is the trap.

Open the entity. The Doctrine class has #[PrePersist] doing tax math. The repository extends ServiceEntityRepository and returns live entities with lazy collections still attached. The “service” layer is three classes deep (OrderService calls OrderManager calls OrderProcessor), and somewhere in that chain a private method decides whether a refund is legal. The controller extends AbstractController, accepts a Request, and trusts the service to figure it out.

None of that is wrong by Symfony’s own standards. It is just framework-coupled all the way down. And the fix is the same exercise you do with Laravel: stop pretending Symfony is the application, and start treating it like what it is: a very capable adapter.

The shape you’re aiming at

The end state is a directory tree where the domain has no idea Symfony exists.

src/
├── Domain/
│   └── Order/              # plain PHP, no Symfony, no Doctrine
├── Application/
│   └── Order/              # use cases, ports as interfaces
└── Infrastructure/
    ├── Http/               # Symfony controllers
    ├── Messenger/          # Messenger handlers
    └── Persistence/
        └── Doctrine/       # adapters + OrderRecord

The rule that makes the layout work is the import graph:

  • Domain/ imports nothing from the project. Pure PHP.
  • Application/ imports Domain/ (ports name domain types).
  • Infrastructure/ imports Application/ and Domain/ (it implements the ports and converts between framework types and domain types).
  • config/services.yaml is the only place that knows about both sides.

If your Domain/ ever imports Symfony* or Doctrine*, the architecture is leaking. One CI check catches it:

! grep -rE "use (Symfony|Doctrine)\\" src/Domain/ 
  || (echo "FAIL: domain imports framework" && exit 1)

The hexagon, with Symfony on the outside

The starting point, in honest detail

Here is the controller most Symfony teams ship. The smells are not subtle.

namespace AppController;

use AppEntityOrder;
use AppRepositoryOrderRepository;
use AppServiceOrderManager;
use DoctrineORMEntityManagerInterface;
use SymfonyBundleFrameworkBundleControllerAbstractController;
use SymfonyComponentHttpFoundationJsonResponse;
use SymfonyComponentHttpFoundationRequest;
use SymfonyComponentRoutingAttributeRoute;

class OrderController extends AbstractController
{
    public function __construct(
        private EntityManagerInterface $em,
        private OrderRepository $orders,
        private OrderManager $orderManager,
    ) {}

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

        $this->em->beginTransaction();
        try {
            $order = new Order();
            $order->setCustomerId($payload['customer_id']);
            $order->setStatus('pending');

            $subtotal = 0;
            foreach ($payload['items'] as $line) {
                $item = new OrderItem();
                $item->setSku($line['sku']);
                $item->setQuantity($line['qty']);
                $order->addItem($item);
                $subtotal += $line['price'] * $line['qty'];
            }
            $order->setSubtotalCents($subtotal);

            $this->orderManager->finalize($order);

            $this->em->persist($order);
            $this->em->flush();
            $this->em->commit();
        } catch (Throwable $e) {
            $this->em->rollback();
            return new JsonResponse(['error' => $e->getMessage()], 422);
        }

        return new JsonResponse([
            'id' => $order->getId(),
            'total' => $order->getTotalCents() / 100,
        ], 201);
    }
}

The controller extends AbstractController. It validates HTTP, walks a cart, applies a coupon rule, delegates the rest to a service chain, and commits a transaction. The “real work” is hidden behind $this->orderManager->finalize($order) — a method that does tax, audit, and warehouse notification.

The entity is no better:

#[ORMEntity(repositoryClass: OrderRepository::class)]
#[ORMHasLifecycleCallbacks]
class Order
{
    #[ORMId, ORMGeneratedValue, ORMColumn]
    private ?int $id = null;

    #[ORMColumn] private int $subtotalCents = 0;
    #[ORMColumn] private int $totalCents = 0;

    #[ORMPrePersist]
    public function applyTax(): void
    {
        $tax = (int) round($this->subtotalCents * 0.21);
        $this->totalCents = $this->subtotalCents + $tax;
    }
}

Tax is the most domain-flavored code in the system, and it lives inside a Doctrine event hook. To unit-test it you need an EntityManager. To run it from a queue worker you have to flush. To run it during a price recalculation job you have to copy the body.

Every test against this controller boots the kernel.

The domain doesn’t extend AbstractController

The first thing that changes is the entity. You write a pure-PHP Order next to the Doctrine one. No attributes, no extends, no Doctrine collections. Just behavior and value objects.

namespace AppDomainOrder;

final class Order
{
    private function __construct(
        public readonly OrderId $id,
        public readonly CustomerId $customerId,
        private array $items,
        private Money $total,
        private OrderStatus $status,
    ) {}

    public static function place(
        OrderId $id,
        CustomerId $customerId,
        array $items,
        TaxPolicy $tax,
    ): self {
        if ($items === []) {
            throw new EmptyCartException();
        }

        $subtotal = Money::sum(
            array_map(fn(LineItem $i) => $i->total(), $items),
        );

        return new self(
            $id,
            $customerId,
            $items,
            $tax->apply($subtotal),
            OrderStatus::Pending,
        );
    }

    public function total(): Money
    {
        return $this->total;
    }
}

There is no EntityManagerInterface. No Request. No AbstractController. Order::place() is callable from anywhere — a controller, a Messenger handler, a CLI command, a test. It does not care which one called it.

The port follows the same rule:

namespace AppDomainOrder;

interface OrderRepository
{
    public function find(OrderId $id): ?Order;
    public function save(Order $order): void;
}

No DoctrinePersistenceManagerRegistry in the constructor. No ServiceEntityRepository parent. The domain says what persistence looks like. The adapter figures out how.

The use case is the application

The use case is the class that orchestrates the domain. It lives in AppApplicationOrderPlaceOrder and it imports zero Symfony namespaces.

namespace AppApplicationOrder;

use AppDomainOrderOrder;
use AppDomainOrderOrderId;
use AppDomainOrderOrderRepository;
use AppDomainOrderTaxPolicy;
use AppDomainCustomerCustomerId;
use AppDomainCustomerCustomerRepository;

final class PlaceOrder
{
    public function __construct(
        private OrderRepository $orders,
        private CustomerRepository $customers,
        private TaxPolicy $tax,
        private IdGenerator $ids,
    ) {}

    public function __invoke(PlaceOrderInput $in): PlaceOrderOutput
    {
        $customer = $this->customers->find(
            new CustomerId($in->customerId),
        );
        if ($customer === null || ! $customer->isActive()) {
            throw new CustomerNotEligible($in->customerId);
        }

        $order = Order::place(
            new OrderId($this->ids->next()),
            $customer->id(),
            $in->lineItems(),
            $this->tax,
        );

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

        return PlaceOrderOutput::from($order);
    }
}

The use case has one job: make a decision and persist its consequence. HTTP, a queue, a console command — any of them can call it without the use case learning where the call came from.

The controller is an adapter (yes, still)

Now the controller. It still uses AbstractController if you want the conveniences. That is fine. What it is not allowed to do is hold business policy.

namespace AppInfrastructureHttp;

use AppApplicationOrderPlaceOrder;
use AppApplicationOrderPlaceOrderInput;
use AppApplicationOrderCustomerNotEligible;
use AppDomainOrderEmptyCartException;
use SymfonyBundleFrameworkBundleControllerAbstractController;
use SymfonyComponentHttpFoundationJsonResponse;
use SymfonyComponentHttpFoundationRequest;
use SymfonyComponentRoutingAttributeRoute;

final class OrderController extends AbstractController
{
    public function __construct(private PlaceOrder $placeOrder) {}

    #[Route('/orders', methods: ['POST'])]
    public function create(Request $request): JsonResponse
    {
        try {
            $output = ($this->placeOrder)(
                PlaceOrderInput::fromJson($request->getContent()),
            );
        } catch (CustomerNotEligible | EmptyCartException $e) {
            return new JsonResponse(
                ['error' => $e->getMessage()],
                422,
            );
        }

        return new JsonResponse(
            ['id' => $output->orderId, 'total' => $output->totalEuros],
            201,
        );
    }
}

Parse the request, call the use case, translate the result, translate the exception. No EntityManager, no OrderRepository, no OrderManager. The controller translates between HTTP and the application. That is the only job it has.

Notice the controller still extends AbstractController. Nothing in the hexagonal rulebook forbids that. The point isn’t to avoid Symfony’s helpers. The point is to keep Symfony out of the domain. The controller sits in Infrastructure/, where Symfony belongs.

Messenger transports are adapters too

The temptation with Messenger is to write the handler as if it were the application. Messenger gives you typed messages, attribute-registered handlers, transports, middleware. It is sophisticated enough that “just put the logic in the handler, it is async anyway” feels reasonable.

It is not. The handler is an adapter — same as the controller. Its job is to translate a message into a use-case call.

namespace AppInfrastructureMessenger;

use AppApplicationOrderPlaceOrder;
use AppApplicationOrderPlaceOrderInput;
use SymfonyComponentMessengerAttributeAsMessageHandler;

#[AsMessageHandler]
final class PlaceOrderMessageHandler
{
    public function __construct(private PlaceOrder $placeOrder) {}

    public function __invoke(PlaceOrderMessage $message): void
    {
        ($this->placeOrder)(new PlaceOrderInput(
            customerId: $message->customerId,
            currency: $message->currency,
            items: $message->items,
        ));
    }
}

Three lines of translation. The use case is the same class the HTTP controller calls. If you swap Messenger for a different transport, or move the work from sync to async behind a feature flag, the only file that changes is the handler. The use case never finds out.

Messenger’s transports config is part of the same adapter ring:

# config/packages/messenger.yaml
framework:
    messenger:
        transports:
            async:
                dsn: '%env(MESSENGER_TRANSPORT_DSN)%'
                retry_strategy:
                    max_retries: 3
                    multiplier: 2

        routing:
            AppInfrastructureMessengerPlaceOrderMessage: async

That YAML is configuration of an adapter. It can change from Doctrine transport to AMQP to Redis without the domain noticing.

Messenger handler as adapter, same use case as HTTP

services.yaml is where the hexagon is wired

Symfony’s DI container is one of the framework’s strongest features. Autowiring with attributes does most of the work for free. The two places it cannot read your mind are: binding interface to implementation, and choosing between multiple implementations of the same interface.

# config/services.yaml
services:
    _defaults:
        autowire: true
        autoconfigure: true

    App:
        resource: '../src/'
        exclude:
            - '../src/Domain/'
            - '../src/Kernel.php'

    AppDomainOrderOrderRepository:
        alias: AppInfrastructurePersistenceDoctrineDoctrineOrderRepository

    AppDomainOrderTaxPolicy:
        alias: AppInfrastructureTaxEuVatTaxPolicy

The two alias lines are the dependency rule in YAML form. Every class in AppApplication* that asks for OrderRepository gets the Doctrine implementation. The application code never imports a Doctrine namespace.

The exclude: ../src/Domain/ line is the one teams forget. Domain classes are values, entities, aggregates — they are not services. Letting Symfony try to autowire Order is a category error. Exclude the directory and the container stays clean.

In tests, override the alias in config/services_test.yaml to point at an in-memory repository. The use-case tests run in milliseconds with no database.

Doctrine lifecycle hooks are not domain methods

The last thing to move out is the #[PrePersist] hook. It looked harmless in the legacy entity. It is not.

A #[PrePersist] tax calculation only runs when the EntityManager flushes. Unit-testing it requires an EntityManager. Running it twice in the same transaction produces surprises. A Messenger handler that places an order through the same code path either skips the conversion or runs a parallel one. The hook welds the tax rule to a persistence event.

The fix is mechanical. The pure Order becomes the only place tax math lives. The Doctrine entity (renamed OrderRecord) becomes a flat data carrier: columns and getters, no #[HasLifecycleCallbacks], no #[PrePersist]. The mapper between OrderRecord and Order lives in the Doctrine adapter, and it is the only file in the codebase that imports both namespaces.

namespace AppInfrastructurePersistenceDoctrine;

final class DoctrineOrderRepository implements OrderRepository
{
    public function __construct(
        private EntityManagerInterface $em,
        private OrderMapper $mapper,
    ) {}

    public function find(OrderId $id): ?Order
    {
        $record = $this->em->find(OrderRecord::class, $id->value);
        if ($record === null) {
            return null;
        }
        $record->getItems()->initialize();
        return $this->mapper->toDomain($record);
    }

    public function save(Order $order): void
    {
        $record = $this->em->find(OrderRecord::class, $order->id->value)
            ?? new OrderRecord($order->id->value);

        $this->mapper->applyToRecord($order, $record);

        $this->em->persist($record);
        $this->em->flush();
    }
}

The repository hands the use case a fully materialized Order with no proxies, no PersistentCollection, and no lazy traps. The initialize() call is explicit on purpose: the mapper is allowed to assume the items are loaded because the repository guarantees it.

What you keep, what you let Symfony do

Keep these in your code:

  • Domain/ with no Symfony imports and no Doctrine imports.
  • Ports (interfaces) defined in domain language, in Domain/.
  • Use cases in Application/ with constructor-injected ports.
  • One CI check that fails if Domain/ imports Symfony or Doctrine.

Let Symfony do these — and stop pretending it shouldn’t:

  • Routing, controller resolution, request parsing, response writing.
  • Messenger transports, retries, dead-letter routing.
  • The DI container, autowiring, services.yaml aliases.
  • Doctrine persistence, migrations, query building.
  • Validator, Mailer, HttpClient — used inside adapters, never inside the domain.

Symfony is a very capable adapter, and the more of it you let do its job, the less code you write. The mistake is letting Symfony into the part of the code that should outlive it.

If you want a place to start on Monday morning, pick the one entity in your codebase with a #[PrePersist] or #[PreUpdate] hook doing business math. Write the pure-PHP twin next to it, move the math, and let the Doctrine class shrink to columns and getters. That single refactor is the smallest version of the whole exercise, and the rest of the codebase will tell you where to go next.

If this was useful

This is the Symfony slice of a longer playbook. Decoupled PHP walks both Laravel and Symfony migrations end to end — full reference application, real refactor, the strangler year, the gotchas no Doctrine tutorial mentions. If your team has been arguing about where business logic should live and the answer keeps being “in the service layer,” the book is what that argument looks like resolved.

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.