---
title: 'Cutting Your Events the Right Way'
date: '2026-07-07'
author: 'daniel-badura'
tags: ['PHP', 'EventSourcing', 'EventDesign']
contentPreview: 'One of the first things that trips up newcomers to event sourcing is figuring out how to cut their events. Should it be one big event or many small ones? There is no universal answer, it depends on your domain and on how much the decision you are recording actually changes. In this first part of a two-part series we model the same information in two very different ways and work out when each one is the right call.'
---

When I introduce people to [event sourcing](/docs/event-sourcing/latest), the questions are rarely about
the mechanics. People grasp aggregates, the event store, and projections reasonably quickly. The thing that makes
them freeze is much more fundamental: "How do I cut my events?" How big should an event be? Is one event
enough, or do I need ten? And the honest answer is the one nobody likes to hear: it depends.

I get it, that answer is frustrating when you just want to start. But event design is genuinely
domain-specific. The "right" cut for one context is the wrong cut for another, and most of the difficulty
comes from not having a feeling yet for what an event actually represents. So instead of giving you rules
that fall apart on the first real project, let me show you the same information modeled in two very different
ways, and then we can talk about when each one is the better choice.

And before you start to panic about getting it wrong: you will get some of it wrong, and that is fine. In
the second part of this series we look at how to evolve events after the
fact, so a wrong first cut is never a dead end.

## Why the size of an event matters

An event is not a database row. That is the single most important sentence in this whole post. A row
describes the current state of a thing. An [event](/docs/event-sourcing/latest/events) describes something
that happened, and it carries **intent**.

In event sourcing your events do two jobs at once. They are the source of truth you rebuild your aggregate
state from, and they are the signal your [subscribers](/docs/event-sourcing/latest/subscription)
react to: projections building your read models, processors triggering side effects, or anything custom you
plug in. How you cut your events directly shapes both. A processor that wants to send a verification mail
whenever a customer changes their email address can only do that cleanly if "the email address changed" is
a thing that exists in your event stream. If all you ever record is "the customer was updated", that
processor has to load the previous state, diff it against the new state, and guess what the user actually
intended. That is a smell, and it comes straight from cutting the event too coarse.

## The two extremes, and what lies between

It helps to look at the two extreme endpoints of event design first, because both of them make the same
mistake: they slice along your data model instead of your business.

On one end you have the **database-row-update** event, the CRUD event. One fat event that mirrors the
record: a full copy of everything, dumped into a single payload on every change, essentially "here is the
new state, deal with it". It is an `UPDATE customers SET ...` statement in event clothing. From that blob
you can still work out *what* changed by diffing it against the previous state, but *why* it changed, the
decision that triggered it, was never recorded at all.

On the other end you have **property-update** events: one event per field. `CustomerStreetChanged`,
`CustomerZipChanged`, `CustomerCityChanged`. That looks disciplined, but it shreds a single decision into
fragments. When a customer moves, the one thing that actually happened arrives as four or five separate
events, and every subscriber has to stitch them back together and guess whether they even belong to the
same decision. The intent is lost here too, this time not because it was never captured, but because it
arrives cut into pieces.

Both extremes are wrong, and for the same reason: rows and columns are technical units, not business ones.
What you actually want lies in between: **business events**, what the literature calls domain events. A
business event is named after something that happened in the business, and it carries exactly the data that
belongs to that happening. A `CustomerMoved` carries the whole address, street, house number, zip, city,
country, because moving is a single decision. The line you are cutting along is the business change, not
the whole record and not the individual field.

But "in between" is still a lot of room. Business events span a spectrum of their own, from coarse to
fine-grained, and where on it you should land depends on your situation. Let me make that concrete with a
customer.

## The same information, two designs

Imagine we manage customers. A customer has a name, an email address, a phone number, and an address. We
want to capture all of this in events. Here is the same information cut two completely different ways.

### Design A: one big event

```php
use Patchlevel\EventSourcing\Aggregate\Uuid;
use Patchlevel\EventSourcing\Attribute\Event;

#[Event('customer.duplicated')]
final class CustomerDuplicated
{
    public function __construct(
        public readonly Uuid $customerId,
        public readonly Uuid $sourceCustomerId,
        public readonly string $name,
        public readonly string $email,
        public readonly string $phone,
        public readonly string $street,
        public readonly string $houseNumber,
        public readonly string $zip,
        public readonly string $city,
        public readonly string $country,
    ) {
    }
}
```

One event, the whole record. The payload is almost indistinguishable from the database-row-update extreme:
a full copy of everything in a single blob. The difference is the name. `customer.duplicated` records a real
business decision: someone took an existing customer and created a new one as a copy. One deliberate action
whose outcome happens to be an entire record.

### Design B: small events

```php
use Patchlevel\EventSourcing\Aggregate\Uuid;
use Patchlevel\EventSourcing\Attribute\Event;

#[Event('customer.registered')]
final class CustomerRegistered
{
    public function __construct(
        public readonly Uuid $customerId,
        public readonly string $name,
        public readonly string $email,
    ) {
    }
}

#[Event('customer.email_changed')]
final class CustomerEmailChanged
{
    public function __construct(
        public readonly Uuid $customerId,
        public readonly string $email,
    ) {
    }
}

#[Event('customer.moved')]
final class CustomerMoved
{
    public function __construct(
        public readonly Uuid $customerId,
        public readonly string $street,
        public readonly string $houseNumber,
        public readonly string $zip,
        public readonly string $city,
        public readonly string $country,
    ) {
    }
}

#[Event('customer.phone_changed')]
final class CustomerPhoneChanged
{
    public function __construct(
        public readonly Uuid $customerId,
        public readonly string $phone,
    ) {
    }
}
```

Both designs end up carrying the same information, and from both you can rebuild the same current state.
But they tell very different stories. Design A says "this customer came into existence as a copy of
that one, and here is everything about it". Design B says "the customer registered, then later moved, then
changed their phone number". One big fact versus a trail of small ones.

So which one should you pick? That depends on a question that has nothing to do with technology: **how much
does the decision you are recording actually change?** Sometimes a decision touches a single field,
sometimes its outcome genuinely spans the whole record, and one common reason for the latter is that the
decision did not happen in your system at all.

## When the big event is the right call

The big, coarse event gets a bad reputation because it looks so much like the database-row-update extreme.
But the payload alone does not make it wrong. What matters is whether the decision behind it really changes
that much.

Duplicating a customer is exactly that. A user looks at an existing customer, clicks "duplicate", and a
complete new record comes into existence. Recording that as `CustomerRegistered` plus `CustomerMoved` plus
`CustomerPhoneChanged` would invent a whole history of decisions that never happened: nobody registered,
nobody moved. There was one decision, and its natural payload is the entire record. The big
`CustomerDuplicated` event is not a compromise here, it is the precise cut.

The other classic source of whole-record decisions is the one hinted at above: **external input**. Picture
syncing customers from an external CRM or ERP. That system is the source of truth. It does not tell you
"the user changed their email", it just hands you the full record, periodically or via a webhook, and says
"this is what the customer looks like now". You never observed the fine-grained intent, because it happened
in a system you do not control. The only decision available for you to record is "a new version of this
record arrived", so a coarse `CustomerSynced` event carrying the whole upstream payload is the honest
choice. Trying to reconstruct fine-grained events instead would mean diffing the new payload against the
old one and inventing intent you never actually witnessed. That is not modeling, that is guessing, and your
"events" would be lies dressed up as facts. This is the classic shape of an anti-corruption layer, where
you faithfully record what the foreign system told you and translate it into your own model afterwards.

That said, this is a rule of thumb, not a law. Nothing stops you from keeping the honest `CustomerSynced`
event and, on top of it, splitting selected information out into smaller, more meaningful events. If the
upstream payload changes an email address and a re-verification flow depends on it, deriving a
`CustomerEmailChanged` from the diff can be worth it. The test is simple: only do this where you could plausibly
pretend you owned that decision. If the derived intent is reliable enough that you would be comfortable
recording it as your own fact, split it out. If you would only be guessing, leave it inside the sync event
where it belongs.

So coarse events are the right tool when the outcome of the decision you are recording genuinely covers the
whole record, either because a single action produced all of it, like a duplication, or because a foreign
system only ever shows you the new state.

## When small events are the right call

The opposite is true the moment **a decision only changes a small piece of the record**. When a change happens inside your
system, triggered by a user or a process you control, you know exactly which decision it was, and throwing
that away is a waste.

If a customer changes their email address in your application, you know it was an email change. Recording it
as `CustomerEmailChanged` means a processor can kick off a re-verification flow without diffing anything. If a
customer moves, `CustomerMoved` can trigger a tax recalculation. Each event maps to a real business decision,
and every part of your system that cares about that decision can subscribe to it directly.

This is where event sourcing earns its keep. The history is not a side effect, it is the product. You can
answer questions like "how often do customers move within their first year" precisely because "moved" is a
first-class fact in your stream and not something you would have to guess at by diffing generic updates.

So if you were hoping for a default after all: this is it. Most decisions in a typical system change only a
small piece of the record, someone changes an email address, moves, updates a phone number, so the
fine-grained, intent-revealing side is where you want to be whenever you can. It preserves the most intent,
and everything event sourcing is good at flows from that. The coarse event is not a style choice: it is
right when one decision really does produce a whole record, like a duplication, or when the intent is
simply not available to you, like a sync.

![cutting-event-granularity-spectrum](/images/blog/cutting-event-granularity-spectrum.png)

## Rules of thumb and smells

There is no formula, but after enough projects a few heuristics hold up well.

**Model decisions, not rows.** Ask yourself "what did the user or the business actually do here", and let
the answer name your event. If you cannot describe an event without saying "and", it might be two events.

**Name events after what happened, in the past tense.** `CustomerMoved`, not `UpdateAddress`. And use the
words your domain experts use, the ubiquitous language: if the business says a customer "moves", the event
is `CustomerMoved`, not `AddressChanged`. The
[docs recommend](/docs/event-sourcing/latest/events) prefixing with the aggregate name and lowercasing, like
`customer.moved`. A generic `customer.updated` is the loudest smell there is, because it names no decision
at all and throws away the very intent that makes the event worth recording.

**Watch for the property bag.** If a single event carries every field of the entity and you send it on
every change, no matter what actually changed, you have quietly rebuilt the database-row-update extreme.
That shape is only right when the decision really spans the whole record, a duplication, an import, a sync,
and a problem everywhere else, because the actual, smaller decision is gone.

But do not overcorrect either. **Do not slice finer than the business actually behaves.** If your UI has a
single "edit profile" form that changes name and email together as one deliberate action, and nothing in
your domain treats those as separate decisions, then one `CustomerProfileEdited` event can be perfectly
reasonable. Splitting it into `CustomerNameChanged` plus `CustomerEmailChanged` only pays off if those changes
carry distinct meaning, otherwise you are just drifting toward the property-update extreme.
This is also why task-based UIs and fine-grained events tend to show up together: a
dedicated "move" action in the UI hands you the intent for free, while a generic edit form forces you to
infer it. Granularity is a means to capture intent, not a goal in itself.

A handy signal for the opposite direction: if you keep pointing several events at one apply method, and your
subscribers keep handling those same events together in a single method, that is your code telling you the
split is not real. When nothing ever reacts to those events separately, they probably want to be one event.

## Wrap up

There is no universal right way to cut events, but there are two universal wrong ways: events that mirror
database rows and events that mirror single properties. Both slice along your data model and lose the
intent. Real business events live between those extremes, on a spectrum from coarse to fine-grained, and
where you land on it follows your domain. The most useful lens I know is how much a single decision
actually changes: cut the event along the decision, and let the payload carry exactly what that decision
changed, no more and no less.
When one action genuinely produces a whole record, like a `CustomerDuplicated`, or an external system only
ever hands you the current state, like a `CustomerSynced`, the coarse event is the honest choice. When a
decision only touches a field or two and you witnessed it yourself, fine-grained events like `CustomerEmailChanged` and
`CustomerMoved` let the rest of your system react cleanly and turn your history into something genuinely
valuable.

And if you read all of this and still worry about getting it wrong, good news: you can change your mind
later. Events are immutable, but that does not mean your design is frozen forever. In the
next part we will take the small events from this post and evolve them step by
step, from a quick backward-compatible tweak all the way to cleaning up the store, without ever breaking the
history we worked so hard to capture.
