With event sourcing, you have the flexibility to structure your data models however you like. You can also store your data models in a different datastore from your events. In this article, we'll learn how to speed things up a bit by storing our read models in memory. Since we can recreate our app state from the events, there isn't really a need to store our read models in database tables. There are various available option: SQLite memory database, Redis, Memcached etc.

We won't be using SQLite memory database as the whole point is to move away from databases. Between Redis and Memcached, it comes down to preference. We'll be using Redis in this tutorial since it's the more popular option.

On startup, we'll replay all events (i.e. run them through the various projectors) to recreate our app state and store the resulting state in memory. Since we're not using a database, we are freed from the restrictions of having to think about our read models in terms of tables and rows.

Our contact read models in memory will look like:

{
  id: '',
  user_id: '',
  name: '',
  gender: '',
  notes: [
    {
      id: '',
      contact_id: '',
      title: '',
      text: ''
    }
  ],
  addresses: [
    {
      id: '',
      contact_id: '',
      key: '',
      value: ''
    }
  ]
}

First, we'll use igbinary, a serializer that's more performant and memory-efficient that the PHP default serializer. Install and setup as directed on the project page.

Install PhpRedis (I have a guide for you if you use an Ubuntu or Ubuntu-ish system). We're using PhpRedis instead of the Laravel support Predis for performance reasons (see this, though you should take such things with a grain of salt).
Since Laravel doesn't support PhpRedis out of the box, we'll use the longman/laravel-lodash package. Install with composer require longman/laravel-lodash.

Copy the package config and translations with:

php artisan vendor:publish --provider="Longman\LaravelLodash\LodashServiceProvider"

After that include Cache and Redis service providers in app.php before your App providers:

Longman\LaravelLodash\Cache\CacheServiceProvider::class,
Longman\LaravelLodash\Redis\RedisServiceProvider::class,
...

Set the redis client in database.php to phpredis or set REDIS_CLIENT to phpredis in your .env file(s). Also, set the serializer to igbinary. Your redis config should look somehow like:

'redis' => [

        'client' => env('REDIS_CLIENT', 'phpredis'),

        'options' => [
            'cluster' => env('REDIS_CLUSTER', 'predis'),
            'prefix' => env('REDIS_PREFIX', 'ab_'),
            'serializer' => 'igbinary',
        ],

        'default' => [
            'host' => env('REDIS_HOST', '127.0.0.1'),
            'password' => env('REDIS_PASSWORD', null),
            'port' => env('REDIS_PORT', 6379),
            'database' => (int) env('REDIS_DB', 0),
            'read_timeout' => 60,
        ],

        'cache' => [
            'host' => env('REDIS_HOST', '127.0.0.1'),
            'password' => env('REDIS_PASSWORD', null),
            'port' => env('REDIS_PORT', 6379),
            'database' => env('REDIS_CACHE_DB', 1),
        ],

    ],

First, delete the note, address and contact migrations and their tables (don't panic, so long as the events are still available, your data is intact). Then, we'll refactor our models. Since we'll no longer be storing our contact, address and note in our database, we no longer require Eloquent. Modify the User model to look like this:

<?php

namespace App\Models;

use Illuminate\Notifications\Notifiable;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Laravel\Passport\HasApiTokens;
use Illuminate\Support\Facades\Redis;

class User extends Authenticatable {
  use Traits\UsesUuid, HasApiTokens, Notifiable;

  protected $guarded = [];

  public $incrementing = false;

  /**
   * The attributes that should be hidden for arrays.
   *
   * @var array
   */
  protected $hidden = [
    'password', 'remember_token',
  ];

  public function contacts() {
    $userKey = static::key($this->id);

    return Redis::exists($userKey) ? Redis::mget(Redis::get($userKey)) : [];
  }

  public function findContact(string $id) : ?Contact {
    return array_reduce($this->contacts(), function ($contact, $c) use($id) { return $c->id === $id ? $c : $contact; }, null);
  }

  public static function storeContact(string $id, string $contactKey) {
    $userKey = static::key($id);
    $contactKeys = Redis::exists($userKey) ? Redis::get($userKey) : [];

    $contactKeys = array_filter($contactKeys, function ($k) use($contactKey) { return $k !== $contactKey; });

    return Redis::set($userKey, array_merge($contactKeys, [$contactKey]));
  }

  public static function removeContact(string $id, string $contactKey) {
    $userKey = static::key($id);
    $contacts = array_filter(Redis::get($userKey), function ($k) use($contactKey) { return $k !== $contactKey; });

    return count($contacts) ? Redis::set($userKey, $contacts) : Redis::del($userKey);
  }

  private static function key(string $id) : string {
    return "user:$id";
  }
}

Our Contact, Note and Address models are now:

<?php

namespace App\Models;

use Illuminate\Support\Facades\Redis;

class Contact {
  /** @var string */
  public $id;
  /** @var string */
  public $user_id;
  /** @var string */
  public $name;
  /** @var string */
  public $gender;
  /** @var array */
  public $notes;
  /** @var array */
  public $addresses;

  public function __construct(string $id,
    string $userId,
    string $name,
    string $gender,
    array $notes,
    array $addresses) {
    $this->id = $id;
    $this->user_id = $userId;
    $this->name = $name;
    $this->gender = $gender;
    $this->notes = $notes;
    $this->addresses = $addresses;
  }

  public function user() : ?User {
    return User::find($this->user_id);
  }

  public function notes() : array {
    return $this->notes;
  }

  public function addresses() : array {
    return $this->addresses;
  }

  public static function find(string $id) : ?Contact {
    return Redis::get(static::key($id));
  }

  public static function store(Contact $contact) {
    if (! Redis::exists(static::key($contact->id))) User::storeContact($contact->user_id, static::key($contact->id));

    return Redis::set(static::key($contact->id), $contact);
  }

  public static function remove(string $id) {
    $contact = static::find($id);

    if ($contact) {
      $key = static::key($id);

      User::removeContact($contact->user_id, $key);

      return Redis::del($key);
    }

    return false;
  }

  private static function key(string $id) : string {
    return "contact:$id";
  }
}
<?php

namespace App\Models;

use Illuminate\Support\Facades\Redis;


class Note {
  /** @var string */
  public $id;
  /** @var string */
  public $contact_id;
  /** @var string */
  public $title;
  /** @var string */
  public $text;

  public function __construct(string $id, string $contactId, string $title, string $text) {
    $this->id = $id;
    $this->contact_id = $contactId;
    $this->title = $title;
    $this->text = $text;
  }

  public function contact() : ?Contact {
    return Contact::find($this->contact_id);
  }

  public static function find(string $id) : ?Note {
    // First, get the containing contact
    $contact = Contact::find(Redis::get(static::key($id)));
    $notes = empty($contact) ? [] : $contact->notes;

    // Find and return the note with the supplied id
    return array_reduce(
      $notes,
      function ($currNote, $n) use($id) { return $n->id === $id ? $n : $currNote; },
      null
    );
  }

  public static function store(Note $note) {
    $contact = Contact::find($note->contact_id);

    if ($contact) {
      // First, get other notes...
      $notes = array_filter($contact->notes, function ($n) use($note) { return $n->id !== $note->id; });
      // ...then, add this note.
      $contact->notes = array_merge($notes, [$note]);

      Contact::store($contact);
      // Since notes are nested in contacts we store a pointer to the containing contact.
      return Redis::set(static::key($note->id), $note->contact_id);
    }

    return false;
  }

  public static function remove(string $id) {
    // First, get the containing contact
    $contact = Contact::find(Redis::get(static::key($id)));

    if ($contact) {
      // Remove this note from the array
      $contact->notes = array_filter($contact->notes, function ($n) use($id) { return $n->id !== $id; });

      return Contact::store($contact);
    }

    return false;
  }

  private static function key(string $id) : string {
    return "note:$id";
  }
}
<?php

namespace App\Models;

use Illuminate\Support\Facades\Redis;

class Address {
  /** @var string */
  public $id;
  /** @var string */
  public $contact_id;
  /** @var string */
  public $key;
  /** @var string */
  public $value;

  public function __construct(string $id, string $contactId, string $key, string $value) {
    $this->id = $id;
    $this->contact_id = $contactId;
    $this->key = $key;
    $this->value = $value;
  }

  public function contact() : ?Contact {
    return Contact::find($this->contact_id);
  }

  public static function find(string $id) : ?Address {
    // First, get the containing contact
    $contact = Contact::find(Redis::get(static::key($id)));
    $addresses = empty($contact) ? [] : $contact->addresses;

    // Find and return the address with the supplied id
    return array_reduce(
      $addresses,
      function ($currAddress, $a) use($id) { return $a->id === $id ? $a : $currAddress; },
      null
    );
  }

  public static function store(Address $address) {
    $contact = Contact::find($address->contact_id);

    if ($contact) {
      // First, get other addresses...
      $addresses = array_filter($contact->addresses, function ($n) use($address) { return $n->id !== $address->id; });
      // ...then, add this address.
      $contact->addresses = array_merge($addresses, [$address]);

      Contact::store($contact);
      // Since addresses are nested in contacts we store a pointer to the containing contact.
      return Redis::set(static::key($address->id), $address->contact_id);
    }

    return false;
  }

  public static function remove(string $id) {
    // First, get the containing contact
    $contact = Contact::find(Redis::get(static::key($id)));

    if ($contact) {
      // Remove this address from the array
      $contact->addresses = array_filter($contact->addresses, function ($a) use($id) { return $a->id !== $id; });

      return Contact::store($contact);
    }

    return false;
  }

  private static function key(string $id) : string {
    return "address:$id";
  }
}

Next, we have to change our projectors to write to memory rather than database. The updated projectors are shown below:

<?php

namespace App\Domain\Contact\Projectors;

use Spatie\EventProjector\Projectors\Projector;
use Spatie\EventProjector\Projectors\ProjectsEvents;
use App\Domain\Contact\Events\ContactCreated;
use App\Domain\Contact\Events\ContactDeleted;
use App\Domain\Contact\Events\ContactNameChanged;
use App\Domain\Contact\Events\ContactGenderChanged;
use App\Models\Contact;

class ContactProjector implements Projector {
  use ProjectsEvents;

  public function onContactCreated(ContactCreated $event, string $aggregateUuid) {
    Contact::store(new Contact($aggregateUuid, $event->userId, $event->name, $event->gender, [], []));
  }

  public function onContactDeleted(ContactDeleted $event, string $aggregateUuid) {
    Contact::remove($aggregateUuid);
  }

  public function onContactNameChanged(ContactNameChanged $event, string $aggregateUuid) {
    $contact = Contact::find($aggregateUuid);
    $contact->name = $event->name;
    Contact::store($contact);
  }

  public function onContactGenderChanged(ContactGenderChanged $event, string $aggregateUuid) {
    $contact = Contact::find($aggregateUuid);
    $contact->gender = $event->gender;
    Contact::store($contact);
  }
}
<?php

namespace App\Domain\Note\Projectors;

use Spatie\EventProjector\Projectors\Projector;
use Spatie\EventProjector\Projectors\ProjectsEvents;
use App\Domain\Note\Events\NoteCreated;
use App\Domain\Note\Events\NoteDeleted;
use App\Domain\Note\Events\NoteTextChanged;
use App\Domain\Note\Events\NoteTitleChanged;
use App\Models\Note;

class NoteProjector implements Projector {
  use ProjectsEvents;

  public function onNoteCreated(NoteCreated $event, string $aggregateUuid) {
    Note::store(new Note($aggregateUuid, $event->contactId, $event->title, $event->text));
  }

  public function onNoteDeleted(NoteDeleted $event, string $aggregateUuid) {
    Note::remove($aggregateUuid);
  }

  public function onNoteTextChanged(NoteTextChanged $event, string $aggregateUuid) {
    $note = Note::find($aggregateUuid);
    $note->text = $event->text;
    Note::store($note);
  }

  public function onNoteTitleChanged(NoteTitleChanged $event, string $aggregateUuid) {
    $note = Note::find($aggregateUuid);
    $note->title = $event->title;
    Note::store($note);
  }
}
<?php

namespace App\Domain\Address\Projectors;

use Spatie\EventProjector\Projectors\Projector;
use Spatie\EventProjector\Projectors\ProjectsEvents;
use App\Domain\Address\Events\AddressCreated;
use App\Domain\Address\Events\AddressDeleted;
use App\Domain\Address\Events\AddressKeyChanged;
use App\Domain\Address\Events\AddressValueChanged;
use App\Models\Address;

class AddressProjector implements Projector {
  use ProjectsEvents;

  public function onAddressCreated(AddressCreated $event, string $aggregateUuid) {
    Address::store(new Address($aggregateUuid, $event->contactId, $event->key, $event->value));
  }

  public function onAddressDeleted(AddressDeleted $event, string $aggregateUuid) {
    Address::remove($aggregateUuid);
  }

  public function onAddressKeyChanged(AddressKeyChanged $event, string $aggregateUuid) {
    $address = Address::find($aggregateUuid);
    $address->key = $event->key;
    Address::store($address);
  }

  public function onAddressValueChanged(AddressValueChanged $event, string $aggregateUuid) {
    $address = Address::find($aggregateUuid);
    $address->value = $event->value;
    Address::store($address);
  }
}

We need to make some slight changes in our tests to work with our new setup:

<?php

namespace Tests\Feature;

use Tests\TestCase;
use Illuminate\Foundation\Testing\RefreshDatabase;
use App\Domain\Note\Commands\CreateNote;
use App\Domain\Note\Commands\UpdateNote;
use App\Domain\Note\Commands\DeleteNote;
use App\Domain\Contact\Commands\CreateContact;
use App\Models\User;
use App\Models\Contact;
use App\Models\Note;

class NoteTest extends TestCase {
  use RefreshDatabase;

  public function testNoteCreation() {
    $user = User::create($this->getUserAttributes());
    $contact = CreateContact::from($this->getContactAttributes($user))->execute();
    $data = $this->getNoteAttributes($contact);
    $createNoteCommand = CreateNote::from($data);

    $this->assertEquals(count($createNoteCommand->getAttributes()), count($data));
    $this->assertTrue($createNoteCommand->isValid());

    $note = $createNoteCommand->execute();

    $this->assertTrue($note instanceof Note);
    $this->assertEquals($note->title, $data['title']);
    $this->assertEquals($note->text, $data['text']);
    $this->assertEquals($note->contact_id, $contact->id);
  }

  public function testNoteDeletion() {
    $user = User::create($this->getUserAttributes());
    $contact = CreateContact::from($this->getContactAttributes($user))->execute();
    $note = CreateNote::from($this->getNoteAttributes($contact))->execute();

    $this->assertEquals(1, count(Contact::find($contact->id)->notes()));

    $command = DeleteNote::from(['id' => $note->id]);

    $this->assertTrue($command->isValid());

    $command->execute();

    $this->assertEquals(0, count(Contact::find($contact->id)->notes()));
  }

  public function testNoteTitleUpdate() {
    $user = User::create($this->getUserAttributes());
    $contact = CreateContact::from($this->getContactAttributes($user))->execute();
    $note = CreateNote::from($this->getNoteAttributes($contact))->execute();
    $data = ['note_id' => $note->id];
    $data['title'] = $this->getNoteAttributes($contact)['title'];
    $command = UpdateNote::from($data);

    $this->assertTrue($command->isValid());

    $updatedNote = $command->execute();

    $this->assertNotEquals($updatedNote->title, $note->title);
    $this->assertEquals($updatedNote->title, $data['title']);
    $this->assertEquals($updatedNote->text, $note->text);
  }

  public function testNoteTextUpdate() {
    $user = User::create($this->getUserAttributes());
    $contact = CreateContact::from($this->getContactAttributes($user))->execute();
    $note = CreateNote::from($this->getNoteAttributes($contact))->execute();
    $data = ['note_id' => $note->id];
    $data['text'] = $this->getNoteAttributes($contact)['text'];
    $command = UpdateNote::from($data);

    $this->assertTrue($command->isValid());

    $updatedNote = $command->execute();

    $this->assertNotEquals($updatedNote->text, $note->text);
    $this->assertEquals($updatedNote->text, $data['text']);
    $this->assertEquals($updatedNote->title, $note->title);
  }

  public function testNoteUpdateWithUnchangedData() {
    $user = User::create($this->getUserAttributes());
    $contact = CreateContact::from($this->getContactAttributes($user))->execute();
    $note = CreateNote::from($this->getNoteAttributes($contact))->execute();
    $data = ['note_id' => $note->id, 'title' => $note->title, 'text' => $note->text];

    $command = UpdateNote::from($data);

    $this->assertTrue($command->isValid());

    $updatedNote = $command->execute();

    $this->assertEquals($updatedNote->title, $note->title);
    $this->assertEquals($updatedNote->text, $note->text);
  }
}
<?php

namespace Tests\Feature;

use Tests\TestCase;
use Illuminate\Foundation\Testing\RefreshDatabase;
use App\Domain\Contact\Commands\CreateContact;
use App\Domain\Contact\Commands\DeleteContact;
use App\Domain\Contact\Commands\UpdateContact;
use App\Models\User;
use App\Models\Contact;

class ContactTest extends TestCase {
  use RefreshDatabase;

  public function testContactCreation() {
    $user = User::create($this->getUserAttributes());
    $data = $this->getContactAttributes($user);
    $createContactCommand = CreateContact::from($data);

    $this->assertEquals(count($createContactCommand->getAttributes()), count($data));
    $this->assertTrue($createContactCommand->isValid());

    $contact = $createContactCommand->execute();

    $this->assertTrue($contact instanceof Contact);
    $this->assertEquals($contact->name, $data['name']);
    $this->assertEquals($contact->gender, $data['gender']);
    $this->assertEquals($contact->user_id, $user->id);
  }

  public function testContactDeletion() {
    $user = User::create($this->getUserAttributes());
    $contact = CreateContact::from($this->getContactAttributes($user))->execute();

    $this->assertEquals(1, count($user->contacts()));

    $command = DeleteContact::from(['id' => $contact->id]);

    $this->assertTrue($command->isValid());

    $command->execute();

    $this->assertEquals(0, count($user->contacts()));
  }

  public function testContactNameUpdate() {
    $user = User::create($this->getUserAttributes());
    $contact = CreateContact::from($this->getContactAttributes($user))->execute();
    $data = ['contact_id' => $contact->id];
    $data['name'] = $this->getContactAttributes($user)['name'];
    $command = UpdateContact::from($data);

    $this->assertTrue($command->isValid());

    $updatedContact = $command->execute();

    $this->assertNotEquals($updatedContact->name, $contact->name);
    $this->assertEquals($updatedContact->name, $data['name']);
    $this->assertEquals($updatedContact->gender, $contact->gender);
  }

  public function testContactGenderUpdate() {
    $user = User::create($this->getUserAttributes());
    $contact = CreateContact::from($this->getContactAttributes($user))->execute();
    $data = ['contact_id' => $contact->id];
    $genderParam = $this->getContactAttributes($user)['gender'];

    // Since we're randomly choosing genders, we want to continue choosing till we get a gender that's different from that of the contact
    while ($genderParam == $contact->gender) $genderParam = $this->getContactAttributes($user)['gender'];

    $data['gender'] = $genderParam;
    $command = UpdateContact::from($data);

    $this->assertTrue($command->isValid());

    $updatedContact = $command->execute();

    $this->assertNotEquals($updatedContact->gender, $contact->gender);
    $this->assertEquals($updatedContact->gender, $data['gender']);
    $this->assertEquals($updatedContact->name, $contact->name);
  }

  public function testContactUpdateWithUnchangedData() {
    $user = User::create($this->getUserAttributes());
    $contact = CreateContact::from($this->getContactAttributes($user))->execute();
    $data = ['contact_id' => $contact->id, 'name' => $contact->name, 'gender' => $contact->gender];

    $command = UpdateContact::from($data);

    $this->assertTrue($command->isValid());

    $updatedContact = $command->execute();

    $this->assertEquals($updatedContact->name, $contact->name);
    $this->assertEquals($updatedContact->gender, $contact->gender);
  }
}
<?php

namespace Tests\Feature;

use Tests\TestCase;
use Illuminate\Foundation\Testing\RefreshDatabase;
use App\Domain\Address\Commands\CreateAddress;
use App\Domain\Address\Commands\UpdateAddress;
use App\Domain\Address\Commands\DeleteAddress;
use App\Domain\Contact\Commands\CreateContact;
use App\Models\User;
use App\Models\Contact;
use App\Models\Address;

class AddressTest extends TestCase {
  use RefreshDatabase;

  public function testEmailAddressCreation() {
    $user = User::create($this->getUserAttributes());
    $contact = CreateContact::from($this->getContactAttributes($user))->execute();
    $data = $this->getAddressAttributes($contact, 'email');
    $createAddressCommand = CreateAddress::from($data);

    $this->assertEquals(count($createAddressCommand->getAttributes()), count($data));
    $this->assertTrue($createAddressCommand->isValid());

    $address = $createAddressCommand->execute();

    $this->assertTrue($address instanceof Address);
    $this->assertEquals($address->key, $data['key']);
    $this->assertEquals($address->value, $data['value']);
    $this->assertEquals($address->contact_id, $contact->id);
  }

  public function testPhoneAddressCreation() {
    $user = User::create($this->getUserAttributes());
    $contact = CreateContact::from($this->getContactAttributes($user))->execute();
    $data = $this->getAddressAttributes($contact, 'phone');
    $createAddressCommand = CreateAddress::from($data);

    $this->assertEquals(count($createAddressCommand->getAttributes()), count($data));
    $this->assertTrue($createAddressCommand->isValid());

    $address = $createAddressCommand->execute();

    $this->assertTrue($address instanceof Address);
    $this->assertEquals($address->key, $data['key']);
    $this->assertEquals($address->value, $data['value']);
    $this->assertEquals($address->contact_id, $contact->id);
  }

  public function testPhysicalAddressCreation() {
    $user = User::create($this->getUserAttributes());
    $contact = CreateContact::from($this->getContactAttributes($user))->execute();
    $data = $this->getAddressAttributes($contact, 'physical');
    $createAddressCommand = CreateAddress::from($data);

    $this->assertEquals(count($createAddressCommand->getAttributes()), count($data));
    $this->assertTrue($createAddressCommand->isValid());

    $address = $createAddressCommand->execute();

    $this->assertTrue($address instanceof Address);
    $this->assertEquals($address->key, $data['key']);
    $this->assertEquals($address->value, $data['value']);
    $this->assertEquals($address->contact_id, $contact->id);
  }

  public function testAddressDeletion() {
    $user = User::create($this->getUserAttributes());
    $contact = CreateContact::from($this->getContactAttributes($user))->execute();
    $address = CreateAddress::from($this->getAddressAttributes($contact, 'email'))->execute();

    $this->assertEquals(1, count(Contact::find($contact->id)->addresses()));

    $command = DeleteAddress::from(['id' => $address->id]);

    $this->assertTrue($command->isValid());

    $command->execute();

    $this->assertEquals(0, count(Contact::find($contact->id)->addresses()));
  }

  public function testAddressUpdate() {
    $user = User::create($this->getUserAttributes());
    $contact = CreateContact::from($this->getContactAttributes($user))->execute();
    $address = CreateAddress::from($this->getAddressAttributes($contact, 'email'))->execute();
    $data = ['address_id' => $address->id, 'key' => 'phone'];
    $data['value'] = $this->getAddressAttributes($contact, 'phone')['value'];
    $command = UpdateAddress::from($data);

    $this->assertTrue($command->isValid());

    $updatedAddress = $command->execute();

    $this->assertNotEquals($updatedAddress->key, $address->key);
    $this->assertNotEquals($updatedAddress->value, $address->value);
    $this->assertEquals($updatedAddress->key, $data['key']);
    $this->assertEquals($updatedAddress->value, $data['value']);
  }

  public function testAddressUpdateFails() {
    $user = User::create($this->getUserAttributes());
    $contact = CreateContact::from($this->getContactAttributes($user))->execute();
    $address = CreateAddress::from($this->getAddressAttributes($contact, 'physical'))->execute();
    $data = ['address_id' => $address->id, 'key' => 'email', 'value' => $address->value];

    $command = UpdateAddress::from($data);

    $this->assertFalse($command->isValid());
    $this->assertNull($command->execute());
  }
}

Run the tests and all of them should pass. Did you also notice the tests are much faster?

Our tests pass but the leave behind old unused objects in our Redis store. Let's write a test hook to flush our Redis store after each test.

In TestCase add the following fixture (remember use Illuminate\Support\Facades\Redis;):

protected function tearDown(): void {
      Redis::flushdb();
}
Ensure you use a different REDIS_DB for tests so only that will be cleared after tests. You might choose not to use a different REDIS_DB, but you'll need to restart the app to rebuild the Redis store.

We also need to modify our controllers to remove Eloquent-specific code.

Change the contacts() method of HomeController to:

public function contacts() {
    return view('contacts', ['contacts' => json_encode(\Auth::user()->contacts())]);
  }

Our controllers have been updated too, as shown below:

<?php

namespace App\Http\Controllers;

use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Auth;
use App\Models\Contact;
use App\Domain\Contact\Commands\CreateContact;
use App\Domain\Contact\Commands\DeleteContact;
use App\Domain\Contact\Commands\UpdateContact;

class ContactController extends Controller {
  public function store(Request $request) {
    $data = $request->all();
    $data['user_id'] = Auth::guard('api')->user()->id;
    $command = CreateContact::from($data);

    if (! $command->isValid()) {
      return response([
        'success' => false,
        'message' => 'Invalid params'
      ], Response::HTTP_UNPROCESSABLE_ENTITY);
    }

    $contact = $command->execute();

    return response([
      'success' => true,
      'message' => 'Created',
      'data' => $contact
    ], Response::HTTP_CREATED);
  }

  public function update(Request $request, $id) {
    $contact = Auth::guard('api')->user()->findContact($id);

    if ($contact) {
      $contact = UpdateContact::from(array_merge($request->all(), ['contact_id' => $id]))->execute();

      return response([
        'success' => (bool) $contact,
        'data' => $contact,
        'message' => ((bool) $contact) ? 'Updated' : 'Not updated'
      ], ((bool) $contact) ? Response::HTTP_OK : Response::HTTP_BAD_REQUEST);
    }

    return response(null, Response::HTTP_FORBIDDEN);
  }

  public function destroy($id) {
    $contact = Auth::guard('api')->user()->findContact($id);

    if ($contact) {
      DeleteContact::from(['id' => $contact->id])->execute();

      return response(null, Response::HTTP_NO_CONTENT);
    }

    return response(null, Response::HTTP_FORBIDDEN);
  }
}
<?php

namespace App\Http\Controllers;

use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Auth;
use App\Models\Note;
use App\Domain\Note\Commands\CreateNote;
use App\Domain\Note\Commands\UpdateNote;
use App\Domain\Note\Commands\DeleteNote;

class NoteController extends Controller {
  public function index($id) {
    $contact = Auth::guard('api')->user()->findContact($id);

    if (! $contact) return response(null, Response::HTTP_FORBIDDEN);

    return response([
      'success' => true,
      'data' => $contact->notes()
    ], Response::HTTP_OK);
  }

  public function store(Request $request) {
    $command = CreateNote::from($request->all());

    if (! $command->isValid()) {
      return response([
        'success' => false,
        'message' => 'Invalid params'
      ], Response::HTTP_UNPROCESSABLE_ENTITY);
    }

    $note = $command->execute();

    return response([
      'success' => true,
      'message' => 'Created',
      'data' => $note
    ], Response::HTTP_CREATED);
  }

  public function update(Request $request, $id) {
    $user = Auth::guard('api')->user();
    $note = Note::find($id);

    if ($note->contact()->userId == $user->id) {
      $note = UpdateNote::from(array_merge(['note_id' => $id], $request->only(['title', 'text'])))->execute();

      return response([
        'success' => (bool) $note,
        'data' => $note,
        'message' => ((bool) $note) ? 'Updated' : 'Not updated'
      ], ((bool) $note) ? Response::HTTP_OK : Response::HTTP_BAD_REQUEST);
    }

    return response(null, Response::HTTP_FORBIDDEN);
  }

  public function destroy($id) {
    $user = Auth::guard('api')->user();
    $note = Note::find($id);

    if ($note->contact()->userId == $user->id) {
      DeleteNote::from(['id' => $note->id])->execute();

      return response(null, Response::HTTP_NO_CONTENT);
    }

    return response(null, Response::HTTP_FORBIDDEN);
  }
}
<?php

namespace App\Http\Controllers;

use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Auth;
use App\Models\Address;
use App\Domain\Address\Commands\CreateAddress;
use App\Domain\Address\Commands\UpdateAddress;
use App\Domain\Address\Commands\DeleteAddress;

class AddressController extends Controller {
  public function index($id) {
    $contact = Auth::guard('api')->user()->findContact($id);

    if (! $contact) return response(null, Response::HTTP_FORBIDDEN);

    return response([
      'success' => true,
      'data' => $contact->addresses()
    ], Response::HTTP_OK);
  }

  public function store(Request $request) {
    $command = CreateAddress::from($request->all());

    if (! $command->isValid()) {
      return response([
        'success' => false,
        'message' => 'Invalid params'
      ], Response::HTTP_UNPROCESSABLE_ENTITY);
    }

    $address = $command->execute();

    return response([
      'success' => true,
      'message' => 'Created',
      'data' => $address
    ], Response::HTTP_CREATED);
  }

  public function update(Request $request, $id) {
    $user = Auth::guard('api')->user();
    $address = Address::find($id);

    if ($address->contact()->userId == $user->id) {
      $address = UpdateAddress::from(array_merge(['address_id' => $id], $request->only(['key', 'value'])))->execute();

      return response([
        'success' => (bool) $address,
        'data' => $address,
        'message' => ((bool) $address) ? 'Updated' : 'Not updated'
      ], ((bool) $address) ? Response::HTTP_OK : Response::HTTP_BAD_REQUEST);
    }

    return response(null, Response::HTTP_FORBIDDEN);
  }

  public function destroy($id) {
    $user = Auth::guard('api')->user();
    $address = Address::find($id);

    if ($address->contact()->userId == $user->id) {
      DeleteAddress::from(['id' => $address->id])->execute();

      return response(null, Response::HTTP_NO_CONTENT);
    }

    return response(null, Response::HTTP_FORBIDDEN);
  }
}

You should be able to login, but you won't see any contacts even if you might have created some.

Since we're storing data in memory, we need to load our existing data into memory when we start our application. We do that by replaying all events i.e. feeding all events into our projectors, which will correctly load all our data to memory. To do this, we'll create a custom command we'll use to start our app instead of the default serve. Our command will replay events before starting the app.

Run php artisan make:command InitStoreAndServe and paste the code below:

<?php

namespace App\Console\Commands;

use Illuminate\Console\Command;
use Spatie\EventProjector\Projectionist;

class InitStoreAndServe extends Command {
  /**
   * The name and signature of the console command.
   *
   * @var string
   */
  protected $signature = 'init:serve';

  /**
   * The console command description.
   *
   * @var string
   */
  protected $description = 'Replay events, then start the server';

  /** @var \Spatie\EventProjector\Projectionist */
  protected $projectionist;

  /**
   * Create a new command instance.
   *
   * @return void
   */
  public function __construct(Projectionist $projectionist) {
    parent::__construct();

    $this->projectionist = $projectionist;
  }

  /**
   * Execute the console command.
   *
   * @return mixed
   */
  public function handle() {
    $this->line("<info>Build read model...</info>");
    $this->replay();

    return $this->call('serve', ['--host' => env('APP_HOST', '0.0.0.0')]);
  }

  private function replay(): void {
    $storeEventClass = config('event-projector.stored_event_model');

    $replayCount = $storeEventClass::startingFrom(0)->count();

    if ($replayCount === 0) {
      $this->warn('There are no events to replay');
      return;
    }

    $this->comment("Replaying $replayCount events...");

    $bar = $this->output->createProgressBar($storeEventClass::count());

    $this->projectionist->replay($this->projectionist->getProjectors(), 0, function () use ($bar) { $bar->advance(); });
    $bar->finish();
    $this->emptyLine(2);
  }

  private function emptyLine(int $amount = 1): void {
    foreach (range(1, $amount) as $i) $this->line('');
  }
}

Start the app by running php artisan init:serve.

You can view all changes here.

PS: If you inspect Redis, you'll notice that deleted address and note pointers are still retained in memory. While the impact is negligible for an app of the size and load, we don't have any use for the pointers after the objects are deleted.

Update the Contact model's remove() to:

public static function remove(string $id) {
    $contact = static::find($id);

    if ($contact) {
      $key = static::key($id);

      User::removeContact($contact->user_id, $key);

      // Remove note pointers
      foreach($contact->notes as $note) Redis::del(Note::key($note->id));
      // Remove address pointers
      foreach($contact->addresses as $address) Redis::del(Address::key($address->id));

      return Redis::del($key);
    }

    return false;
}

Update the Note model's remove() and key() to:

public static function remove(string $id) {
    $key = static::key($id);
    // First, get the containing contact
    $contact = Contact::find(Redis::get($key));

    if ($contact) {
      // Remove this note from the array
      $contact->notes = array_filter($contact->notes, function ($n) use($id) { return $n->id !== $id; });

      Contact::store($contact);

      // Remove pointer
      return Redis::del($key);
    }

    return false;
  }
  
  public static function key(string $id) : string {
    return "note:$id";
  }

Update the Address model's remove() and key() to:

public static function remove(string $id) {
    $key = static::key($id);
    // First, get the containing contact
    $contact = Contact::find(Redis::get($key));

    if ($contact) {
      // Remove this address from the array
      $contact->addresses = array_filter($contact->addresses, function ($a) use($id) { return $a->id !== $id; });

      Contact::store($contact);

      // Remove pointer
      return Redis::del($key);
    }

    return false;
  }

  public static function key(string $id) : string {
    return "address:$id";
  }

With these changes, we won't have stale data in our memory store.

You can view the code changes here.