Let's talk about Event Sourcing. Perhaps you've heard of it, but haven't found the time to attend a conference talk or read one of the older, larger books which describe it. It's one of those topics I wish I'd known about sooner, and today I'm going to describe it to you in a way that I understand it.
Most of this code can be found on GitHub. I've tested it using PHP 7.1
.
I've chosen this title for a few reasons. Firstly, I don't consider myself an expert on the topic. For that, you'd be hard pressed to find a better tutor than the authors of those books, or someone like Mathias Verraes. What I'm about to tell you is only the tip of the iceberg. A pinch of salt, if you will.
Event sourcing is also part of a larger, broader set of topics; loosely defined as Domain Driven Design. Event sourcing is one design pattern amongst many, and you'd do well to learn about the other patterns associated with DDD. In fact, it's often not a good idea to pluck just Event Sourcing out of the DDD toolbox, without understanding the benefits of the other patterns.
Still, I think it's a fascinating and fun exercise, and few people cover it well. It's especially suited for those developers who have yet to dip their toes in the pool of DDD. So, if you find yourself needing something like Event Sourcing, but don't know or understand the rest of DDD, I hope this post helps you. In a pinch.
One of the strongest themes of Domain Driven Design is the need for a common language. When your client decides they need a new application, they are thinking about how it will affect their ice-cream sales. They're concerned about how their patrons will find their favorite flavor of ice-cream, and how that will affect foot-traffic at their ice-cream stand.
You may think in terms of website users and geolocated outlets, but those words don't necessarily mean anything to your client. Though it may take some time, initially, your communication with your client will be greatly improved if you both use the same words when talking about the same thing.
You'll also find that modeling the entire system in the words your client understands gives you a bit of a safety net against scope changes. It's much easier to say; "You initially asked for customers to purchase ice-cream before the invoice is sent (shown here in code and email), but now you're asking for the invoice to be sent first..." than it is to describe the changes they're asking for in language/code only you understand.
That's not to say all your code needs to be understood by the client, or that you have to use something like Behat for your integration testing. But, at the very least, you should call entities and actions the same thing as your client does.
An added benefit of this is that future developers will be able to understand the intent of the code (and how it applies to the business process), without as much help from the client or project manager.
I'm waffling a bit, but this point will be important when we start to write code.
Most of the websites I've built have had some form of CRUD (Create, Read, Update, and Delete) database functionality. These operations are intentionally generic, as they have traditionally mapped to the underlying relational database they use.
We may even be used to using something like Eloquent:
$product = new Product();
$product->title = "Chocolate";
$product->cents_per_serving = 499;
$product->save();
$outlet = new Outlet();
$outlet->location = "Pismo Beach";
$outlet->save();
$outlet->products()->sync([
$product->id => [
"servings_in_stock" => 24,
],
])
This is enough for the most basic presentation of ice-cream information on the client's website. It's how we've been building websites for ages. But it has a significant weakness — we don't know what happened to get us here.
Let's think of some things which could influence how the data got to this point:
created_at
and updated_at
, but those only go so far in telling us what we want to know.The weakness is such that we only know what the data is like now. Our data is like a photo, when what we want is a video. What if we tried something different?
$events = [];
$events[] = new ProductInvented("Chocolate");
$events[] = new ProductPriced("Chocolate", 499);
$events[] = new OutletOpened("Pismo Beach");
$events[] = new OutletStocked("Pismo Beach", 24, "Chocolate");
store($events);
This is storing the same eventual information, but each of the steps is self-contained. They describe the behavior of the customers, outlets, stock etc.
Using this approach, we have much better control over the timeline of events which have lead to the current state. We could add events for stock giveaways, or product discontinuation:
$events = [];
$events[] = new OutletStockGivenAway(
"Pismo Beach", 2, "Chocolate"
);
$events[] = new OutletDiscontinuedProduct(
"Pismo Beach", "Chocolate"
);
store($events);
This isn't more complex than storing state, but it is far more descriptive of the events that happen. It's also really easy for the client to understand what's going on.
When we start to store behavior (instead of the state at one point in time), we gain the ability to easily step through the events. Almost like we're traveling through time:
$lastWeek = Product::at("Chocolate", date("-1 WEEK"));
$yesterday = Product::at("Chocolate", date("-1 DAY"));
printf(
"Chocolate increased, from %s to %s, in one week",
$lastWeek->cents_per_serving,
$yesterday->cents_per_serving
);
... and we could do that without any extra boolean/timestamp fields. We could come back to already-stored data, and create a new kind of report. That's so valuable!
Event Sourcing is both of these things. It's about capturing every event (which you can think of as every change in application data) as a self-contained, repeatable thing. It's about storing these events in the same time-order they happened, so that we can at-will journey to any point in time.
It's about understanding how to interface this architecture with other systems that aren't built in the same way, which means having a way to represent just the latest application data state.
The events are append-only, which means we never delete any of them from the database. And, if we're doing things right, they describe (in their names and properties) what they mean to the business and customer they relate to.
We're going to use classes to describe events. They're useful, simple containers we can define; and they'll help us validate the data we put in and the data we get out for each event.
Those experienced in Event Sourcing may be itching to hear how I describe things like aggregates. I'm intentionally avoiding jargon — in much the same way as I'd avoid differentiating between mocks, doubles, stubs, and fakes — if I were teaching someone their first bit of testing. It's the idea that is important, and the idea behind Event Sourcing is recording behavior.
Here's the abstract event that we can use to model real events:
abstract class Event
{
/**
* @var DateTimeImmutable
*/
private $date;
protected function __construct()
{
$this->date = date("Y-m-d H:i:s");
}
public function date(): string
{
return $this->date;
}
abstract public function payload(): array;
}
This is from events.php
It's really important (in my opinion) that event classes are simple. Using PHP 7 type hints, we can validate the data we use to define events. A handful of simple accessors will help us get the important data out again.
On top of this class, we can define the real event types we want to record:
final class ProductInvented extends Event
{
/**
* @var string
*/
private $name;
public function __construct(string $name)
{
parent::__construct();
$this->name = $name;
}
public function payload(): array
{
return [
"name" => $this->name,
"date" => $this->date(),
];
}
}
final class ProductPriced extends Event
{
/**
* @var string
*/
private $product;
/**
* @var int
*/
private $cents;
public function __construct(string $product, int $cents)
{
parent::__construct();
$this->product = $product;
$this->cents = $cents;
}
public function payload(): array
{
return [
"product" => $this->product,
"cents" => $this->cents,
"date" => $this->date(),
];
}
}
final class OutletOpened extends Event
{
/**
* @var string
*/
private $name;
public function __construct(string $name)
{
parent::__construct();
$this->name = $name;
}
public function payload(): array
{
return [
"name" => $this->name,
"date" => $this->date(),
];
}
}
final class OutletStocked extends Event
{
/**
* @var string
*/
private $outlet;
/**
* @var int
*/
private $servings;
/**
* @var string
*/
private $product;
public function __construct(string $outlet, ↩
int $servings, string $product)
{
parent::__construct();
$this->outlet = $outlet;
$this->servings = $servings;
$this->product = $product;
}
public function payload(): array
{
return [
"outlet" => $this->outlet,
"servings" => $this->servings,
"product" => $this->product,
"date" => $this->date(),
];
}
}
Notice how we've made each of these final
? We have to fight to keep the events simple, and they wouldn't continue to be simple if another developer could come along and subclass them (for whatever reason).
I also find it interesting how we can isolate the definition, format, and accessibility of the event dates: by defining $date
as private and requiring subclasses to access it through the date
method. This is perhaps a tad too defensive, but it obeys the Law of Demeter in that the concrete events need not know how the date is defined or formatted, in order to use it.
With this isolation, we can change the entire system's timezone, or change to using UNIX timestamps, and we'd only need to change a single line of code.
We could omit these classes if we're willing to sacrifice performance (and do runtime associative array checks) or type safety.
Let's store these events in a SQLite database. We could use an ORM for that, but perhaps this is a good opportunity to recap how PDO works.
The first bit of code, for connecting to any supported database through PDO, is:
$connection = new PDO("sqlite::memory:");
$connection->setAttribute(
PDO::ATTR_ERRMODE,
PDO::ERRMODE_EXCEPTION
);
This is from sqlite-pdo.php
PDO connections are typically made using a Data Source Name (DSN). Here we define the database type as sqlite
, and the location as an in-memory database. This means the database will disappear as soon as the script finishes.
It's also a good idea to set the error-mode to throw exceptions when a SQL error occurs. That way we'll get immediate feedback on our mistakes.
If you've not done any raw SQL queries before, this next bit may be confusing. Check out this great introduction to SQL.
Next, we should create some tables to work from:
$statement = $connection->prepare("
CREATE TABLE IF NOT EXISTS product (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT
)
");
$statement->execute();
This is from sqlite-pdo.php
One of those tables is going to be where we generate and store unique product identifiers. The exact syntax of CREATE TABLE
differs slightly between database types, and you'd typically find more columns in a table.
A great way to learn how your database creates tables is to make a table through a GUI, and then run SHOW CREATE TABLE my_new_table
. This will generate CREATE TABLE
syntax, in all of PDO's supported databases.
Prepared statements (using prepare
and execute
) are the recommended way of executing SQL queries. They are even more useful when you need to pass query parameters:
$statement = $connection->prepare(
"INSERT INTO product (name) VALUES (:name)"
);
$statement->bindValue("name", "Chocolate");
$statement->execute();
This is from sqlite-pdo.php
Bound values are automatically quoted and escaped, avoiding the most common kinds of SQL injection. We can also use prepared statements to return rows:
$row = $connection
->prepare("SELECT * FROM product")
->execute()->fetch(PDO::FETCH_ASSOC);
$rows = $connection
->prepare("SELECT * FROM product")
->execute()->fetchAll(PDO::FETCH_ASSOC);
This is from sqlite-pdo.php
These fetch
and fetchAll
methods will return arrays and arrays or arrays, respectively, given that we're using the PDO::FETCH_ASSOC
type.
As you can probably guess, using PDO directly can lead to a lot of needless repetition. I've found it useful to create a few helper functions:
function connect(string $dsn): PDO
{
$connection = new PDO($dsn);
$connection->setAttribute(
PDO::ATTR_ERRMODE,
PDO::ERRMODE_EXCEPTION
);
return $connection;
}
function execute(PDO $connection, string $query, ↩
array $bindings = []): array
{
$statement = $connection->prepare($query);
foreach ($bindings as $key => $value) {
$statement->bindValue($key, $value);
}
$result = $statement->execute();
return [$statement, $result];
}
function rows(PDO $connection, string $query, ↩
array $bindings = []): array
{
$executed = execute($connection, $query, $bindings);
/** @var PDOStatement $statement */
$statement = $executed[0];
return $statement->fetchAll(PDO::FETCH_ASSOC);
}
function row(PDO $connection, string $query, ↩
array $bindings = []): array
{
$executed = execute($connection, $query, $bindings);
/** @var PDOStatement $statement */
$statement = $executed[0];
return $statement->fetch(PDO::FETCH_ASSOC);
}
This is from sqlite-pdo-helpers.php
This are much the same code as we saw before. They're a little nicer to use than the direct PDO code though:
$connection = connect("sqlite::memory:");
execute(
$connection,
"CREATE TABLE IF NOT EXISTS product ↩
(id INTEGER PRIMARY KEY AUTOINCREMENT,name TEXT)"
);
execute(
$connection,
"INSERT INTO product (name) VALUES (:name)",
["name" => "Chocolate"]
);
$rows = rows(
$connection,
"SELECT * FROM product"
);
$row = row(
$connection,
"SELECT * FROM product WHERE name = :name",
["name" => "Chocolate"]
);
This is from sqlite-pdo-helpers.php
You may not like the idea of defining global functions for these things. They're like something you'd see in the dark ages of PHP. But they're so concise, and easy to use!
They're not even difficult to test:
$fake = new class("sqlite::memory:") extends PDO
{
private $valid = true;
function prepare($statement, $options = null) {
if ($statement !== "SELECT * FROM product") {
$this->valid = false;
}
return $this;
}
function execute() {
return;
}
function fetchAll() {
if (!$this->valid) {
throw new Exception();
}
return [];
}
};
assert(connect("sqlite::memory:") instanceof PDO);
assert(is_array(rows($fake, "SELECT * FROM product")));
This is from sqlite-pdo-helpers.php
We're not testing all the variations, of the helper functions, but you get the idea...
If you're still confused, for a more in-depth look at PDO, see this post.
Let's take another look at the events we want to store:
$events = [];
$events[] = new ProductInvented("Chocolate");
$events[] = new ProductPriced("Chocolate", 499);
$events[] = new OutletOpened("Pismo Beach");
$events[] = new OutletStocked("Pismo Beach", 24, "Chocolate");
The simplest approach would be to create a database table for each of these event types:
execute($connection, "
CREATE TABLE IF NOT EXISTS product (
id INTEGER PRIMARY KEY AUTOINCREMENT
)
");
execute($connection, "
CREATE TABLE IF NOT EXISTS event_product_invented (
id INT,
name TEXT,
date TEXT
)
");
execute($connection, "
CREATE TABLE IF NOT EXISTS event_product_priced (
product INT,
cents INT,
date TEXT
)
");
execute($connection, "
CREATE TABLE IF NOT EXISTS outlet (
id INTEGER PRIMARY KEY AUTOINCREMENT
)
");
execute($connection, "
CREATE TABLE IF NOT EXISTS event_outlet_opened (
id INT,
name TEXT,
date TEXT
)
");
execute($connection, "
CREATE TABLE IF NOT EXISTS event_outlet_stocked (
outlet INT,
servings INT,
product INT,
date TEXT
)
");
This is from storing-events.php
In addition to a table for each event, I've also added tables to store and generate product and outlet IDs. Each event table has a date field, the value of which is generated by the abstract Event
class.
The real magic happens in the store
and storeOne
functions:
function store(PDO $connection, array $events)
{
foreach($events as $event) {
storeOne($connection, $event);
}
}
function storeOne(PDO $connection, Event $event)
{
$payload = $event->payload();
if ($event instanceof ProductInvented) {
inventProduct(
$connection,
newProductId($connection),
$payload["name"],
$payload["date"]
);
}
if ($event instanceof ProductPriced) {
priceProduct(
$connection,
productIdFromName($connection, $payload["name"]),
$payload["cents"],
$payload["date"]
);
}
if ($event instanceof OutletOpened) {
openOutlet(
$connection,
newOutletId($connection),
$payload["name"],
$payload["date"]
);
}
if ($event instanceof OutletStocked) {
stockOutlet(
$connection,
outletIdFromName(
$connection, $payload["outlet_id"]
),
$payload["servings"],
productIdFromName(
$connection, $payload["product_id"]
),
$payload["date"]
);
}
}
This is from storing-events.php
The store
function is just a convenience. PHP has no concept of typed arrays, so we could add runtime checking, or use the signature of storeOne
to validate that we're only trying to store Event
subclass instances.
We can get specific event data via the payload
method. This data will differ based on the event class being stored, so we should only assume keys after we're sure which event type we're dealing with.
We're also using some product and outlet helper methods. Here's what they look like:
function newProductId(PDO $connection): int
{
execute(
$connection,
"INSERT INTO product VALUES (null)"
);
return $connection->lastInsertId();
}
function inventProduct(PDO $connection, int $id, ↩
string $name, string $date)
{
execute(
$connection,
"INSERT INTO event_product_invented ↩
(id, name, date) VALUES (:id, :name, :date)",
["id" => $id, "name" => $name, "date" => $date]
);
}
function productIdFromName(PDO $connection, string $name): int
{
$row = row(
$connection,
"SELECT * FROM event_product_invented ↩
WHERE name = :name",
["name" => $name]
);
if (!$row) {
throw new InvalidArgumentException("Product not found");
}
return $row["id"];
}
function priceProduct(PDO $connection, int $product, ↩
int $cents, string $date)
{
execute(
$connection,
"INSERT INTO event_product_priced ↩
(product, cents, date) VALUES ↩
(:product, :cents, :date)",
["product" => $product, "cents" => $cents, ↩
"date" => $date]
);
}
function newOutletId(PDO $connection): int
{
execute(
$connection,
"INSERT INTO outlet VALUES (null)"
);
return $connection->lastInsertId();
}
function openOutlet(PDO $connection, int $id, ↩
string $name, string $date)
{
execute(
$connection,
"INSERT INTO event_outlet_opened (id, name, date) ↩
VALUES (:id, :name, :date)",
["id" => $id, "name" => $name, "date" => $date]
);
}
function outletIdFromName(PDO $connection, string $name): int
{
$row = row(
$connection,
"SELECT * FROM event_outlet_opened ↩
WHERE name = :name",
["name" => $name]
);
if (!$row) {
throw new InvalidArgumentException("Outlet not found");
}
return $row["id"];
}
function stockOutlet(PDO $connection, int $outlet, ↩
int $servings, int $product, string $date)
{
execute(
$connection,
"INSERT INTO event_outlet_stocked ↩
(outlet_id, servings, product_id, date) ↩
VALUES (:outlet, :servings, :product, :date)",
["outlet" => $outlet, "servings" => $servings, ↩
"product" => $product, "date" => $date]
);
}
This is from storing-events.php
inventProduct
, priceProduct
, openOutlet
, and stockOutlet
are all pretty self-explanatory. In order to get the IDs they refer to, we need the newProductId
and newOutletId
functions. These insert empty rows so that unique identifiers will be generated and can be returned (using the $connection->lastInsertId()
method).
You do not have to follow this same naming pattern. In fact, it's better to use names and patterns that you and your client agree define the core concepts of the product, as far as DDD is concerned.
We can test these using a pattern similar to:
store($connection, [
new ProductInvented("Cheesecake"),
]);
$row = row(
$connection,
"SELECT * FROM event_product_invented WHERE name = :name",
["name" => "Cheesecake"]
);
assert(!is_null($row));
This is from storing-events.php
As we've seen, the method of storing behavior gives us an unprecedented look at the entire history of our data. It's not very good for rendering views, though. As I mentioned, we also need a way to interface an event sourcing architecture with other systems that are not built in the same way.
That means we need to be able to tell the outside world what the more recent state of the application is, as if we were storing it like that in the database. This is often called projection, because we sort through all the events to display a final state for everyone else to see. So, projection in the sense of forecasting a future state, based on present trends.
Earlier we saw functions like:
Product::at("Chocolate", date("-1 WEEK"));
// → ["id" => 1, "name" => "Chocolate", ...]
Ideally, we'd also have these methods:
Product::latest();
// → [["id" => 1, "name" => "Chocolate", ...], ...]
Product::latest("Chocolate");
// → ["id" => 1, "name" => "Chocolate", ...]
First, we need to load all the events stored in the database:
function fetch(PDO $connection): array {
$events = [];
$tables = [
ProductInvented::class => "event_product_invented",
ProductPriced::class => "event_product_priced",
OutletOpened::class => "event_outlet_opened",
OutletStocked::class => "event_outlet_stocked",
];
foreach ($tables as $type => $table) {
$rows = rows($connection, "SELECT * FROM {$table}");
$rows = array_map(
function($row) use ($connection, $type) {
return $type::from($connection, $row);
}, $rows
);
$events = array_merge($events, $rows);
}
usort($events, function(Event $a, Event $b) {
return strtotime($a->date()) - strtotime($b->date());
});
return $events;
}
This is from projecting-events.php
There's quite a bit going on here, so let's break it down:
date
of each event, to sort them into chronological order.We need to add these new from
methods to each of our events:
abstract class Event
{
// ...snip
public function withDate(string $date): self
{
$new = clone $this;
$new->date = $date;
return $new;
}
abstract
public
static
function
from(PDO $connection, array $data);
}
final class ProductInvented extends Event
{
// ...snip
public static function from(PDO $connection, array $data)
{
$new = new static(
$data["name"]
);
return $new->withDate($data["date"]);
}
}
final class ProductPriced extends Event
{
// ...snip
public static function from(PDO $connection, array $data)
{
$new = new static(
productNameFromId($connection, $data["product"]),
$data["cents"]
);
return $new->withDate($data["date"]);
}
}
final class OutletOpened extends Event
{
// ...snip
public static function from(PDO $connection, array $data)
{
$new = new static(
$data["name"]
);
return $new->withDate($data["date"]);
}
}
final class OutletStocked extends Event
{
// ...snip
public static function from(PDO $connection, array $data)
{
$new = new static(
outletNameFromId($connection, $data["outlet"]),
$data["servings"],
productNameFromId($connection, $data["product"])
);
return $new->withDate($data["date"]);
}
}
This is from events.php
We're also using a couple of new global functions:
function productNameFromId(PDO $connection, int $id): string {
$row = row(
$connection,
"SELECT * FROM event_product_invented WHERE id = :id",
["id" => $id]
);
if (!$row) {
throw new InvalidArgumentException("Product not found");
}
return $row["name"];
}
function outletNameFromId(PDO $connection, int $id): string {
$row = row(
$connection,
"SELECT * FROM event_outlet_opened WHERE id = :id",
["id" => $id]
);
if (!$row) {
throw new InvalidArgumentException("Outlet not found");
}
return $row["name"];
}
This is from projecting-events.php
The reason we need any of these *NameFromId
and *IdFromName
functions is because we want to create and present the events using entity names, but we want to store them as foreign keys in the database. That's just a personal preference of mine, and you're free to define/present/store them however makes sense to you.
We can now turn a list of events into database rows, and back again:
$events = [];
$events[] = new ProductInvented("Chocolate");
$events[] = new ProductPriced("Chocolate", 499);
$events[] = new OutletOpened("Pismo Beach");
$events[] = new OutletStocked("Pismo Beach", 24, "Chocolate");
store($connection, $events); // ← events stored in database
$stored = fetch($connection); // ← events loaded from database
assert(json_encode($events) === json_encode($stored));
Now, how do we convert this to something usable? We need to define a few more helper functions:
function project(PDO $connection, array $events): array {
$entities = [
"products" => [],
"outlets" => [],
];
foreach ($events as $event) {
$entities = projectOne($connection, $entities, $event);
}
return $entities;
}
function projectOne(PDO $connection, array $entities, ↩
Event $event): array
{
if ($event instanceof ProductInvented) {
$entities = projectProductInvented(
$connection, $entities, $event
);
}
if ($event instanceof ProductPriced) {
$entities = projectProductPriced(
$connection, $entities, $event
);
}
if ($event instanceof OutletOpened) {
$entities = projectOutletOpened(
$connection, $entities, $event
);
}
if ($event instanceof OutletStocked) {
$entities = projectOutletStocked(
$connection, $entities, $event
);
}
return $entities;
}
This is from projecting-events.php
This code is similar to the code we use to store events. For each type of event, we modify the array of entities. After all the events have been projected, we should have the latest state. Here's what the other projector methods look like:
function projectProductInvented(PDO $connection, ↩
array $entities, ProductInvented $event): array
{
$payload = $event->payload();
$entities["products"][] = [
"id" => productIdFromName($connection, $payload["name"]),
"name" => $payload["name"],
];
return $entities;
}
function projectProductPriced(PDO $connection, ↩
array $entities, ProductPriced $event): array
{
$payload = $event->payload();
foreach ($entities["products"] as $i => $product) {
if ($product["name"] === $payload["product"]) {
$entities["products"][$i]["price"] = ↩
$payload["cents"];
}
}
return $entities;
}
function projectOutletOpened(PDO $connection, ↩
array $entities, OutletOpened $event): array
{
$payload = $event->payload();
$entities["outlets"][] = [
"id" => outletIdFromName($connection, $payload["name"]),
"name" => $payload["name"],
"stock" => [],
];
return $entities;
}
function projectOutletStocked(PDO $connection, ↩
array $entities, OutletStocked $event): array
{
$payload = $event->payload();
foreach ($entities["outlets"] as $i => $outlet) {
if ($outlet["name"] === $payload["outlet"]) {
foreach ($entities["products"] as $j => $product) {
if ($product["name"] === $payload["product"]) {
$entities["outlets"][$i]["stock"][] = [
"product" => &$product,
"servings" => $payload["servings"],
];
}
}
}
}
return $entities;
}
This is from projecting-events.php
Each of these projection methods accepts a type of event, and sorts through the event payload to make their mark on the $entities
array.
At this point, we can use the structure we've created to populate a website. Since our projectors accept events, we can even generate an initial projection (at the beginning of a request) and then apply any new events to them, as they happen.
As you can probably guess, this isn't the most efficient way to query a database, just to render a website. If you need the projections a lot of the time, it might be better to periodically project your events and then store the resulting structure in denormalized database tables.
That way, you can capture events (through things like API requests or form posts), and still query "normal" database tables, when displaying data in an application.
So far, we've seen how we can describe, store, and project (to the latest state). Projecting to a specific point in time is just a matter of adjusting the projection functions, so that they apply events up to, or after, a certain timestamp.
We've covered way more than I originally planned to, so I'll leave that last bit as an exercise for you. Think of how you could model the traditional (for CMS applications) model of draft/working and published versions of content.
If you've managed to get this far; well done! It's been a long journey, but worth it I feel. Let us know what you like or don't like about this design pattern. If you want to learn more about Event Sourcing (or DDD in general), definitely check out the books linked at the start.
Code full of arrays can get ugly, fast! I highly recommend you check out Adam Wathan's book, about refactoring loop code to collections.
3.141.28.116