In our previous article, we implemented contact management. In this article, we'll be implementing note creation, deletion and editing.

Our notes will have only title and text attributes. Let's jump right in.

First, create a migration for the notes table by running php artisan make:migration create_notes_table and filling it with these contents:

<?php

use Illuminate\Support\Facades\Schema;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Database\Migrations\Migration;

class CreateNotesTable extends Migration {
  /**
   * Run the migrations.
   *
   * @return void
   */
  public function up() {
    Schema::create('notes', function (Blueprint $table) {
      $table->uuid('id')->primary();
      $table->uuid('contact_id');
      $table->string('title');
      $table->string('text');
      $table->timestamps();
    });
  }

  /**
   * Reverse the migrations.
   *
   * @return void
   */
  public function down() {
    Schema::dropIfExists('notes');
  }
}

Create a Note model in our app/Models folder with these contents:

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;

class Note extends Model {
  use Traits\UsesUuid;

  protected $guarded = [];

  public $incrementing = false;

  public function contact() {
    return $this->belongsTo(Contact::class);
  }
}

Add the following method to our Contact model:

public function notes() {
    return $this->hasMany(Note::class);
}

Create two commands CreateNote and DeleteNote in app/Domain/Note/Comands.

<?php

namespace App\Domain\Note\Commands;

use Ramsey\Uuid\Uuid;
use App\Domain\Command;
use App\Domain\Note\NoteAggregate;
use App\Models\Note;
use App\Models\Contact;

final class CreateNote extends Command {
  public function __construct(array $bag) {
    $this->attributes['contact_id'] = Uuid::isValid($bag['contact_id']) ?
      $bag['contact_id'] :
      Uuid::fromString($bag['contact_id']);

    $this->attributes['title'] = $bag['title'];
    $this->attributes['text'] = $bag['text'];
  }

  public static function from(array $bag) : CreateNote {
    return new CreateNote($bag);
  }

  public function isValid() : bool {
    return ((bool) Contact::find($this->attributes['contact_id'])) &&
      ! empty($this->attributes['title']) &&
      ! empty($this->attributes['text']);
  }

  public function execute() : ?Note {
    if (! $this->isValid()) return null;

    $newId = Uuid::uuid4();

    NoteAggregate::retrieve($newId)
      ->createNote($this->attributes['contact_id'], $this->attributes['title'], $this->attributes['text']);

    return Note::find($newId);
  }
}
<?php

namespace App\Domain\Note\Commands;

use Ramsey\Uuid\Uuid;
use App\Domain\Command;
use App\Domain\Note\NoteAggregate;
use App\Models\Note;

final class DeleteNote extends Command {
  public function __construct(array $bag) {
    $this->attributes['id'] = Uuid::isValid($bag['id']) ? $bag['id'] : Uuid::fromString($bag['id']);
  }

  public static function from(array $bag) : DeleteNote {
    return new DeleteNote($bag);
  }

  public function isValid() : bool {
    return (bool) Note::find($this->attributes['id']);
  }

  public function execute() {
    if (! $this->isValid()) return;

    NoteAggregate::retrieve($this->attributes['id'])->deleteNote($this->attributes['id']);
  }
}

Create two new events NoteCreated and NoteDeleted in app/Domain/Note/Events:

<?php

namespace App\Domain\Note\Events;
use Spatie\EventProjector\ShouldBeStored;

final class NoteCreated implements ShouldBeStored {
  /** @var string */
  public $title;

  /** @var string */
  public $text;

  /** @var string */
  public $contactId;

  public function __construct(string $contactId, string $title, string $text) {
    $this->contactId = $contactId;
    $this->title = $title;
    $this->text = $text;
  }
}
<?php

namespace App\Domain\Note\Events;
use Spatie\EventProjector\ShouldBeStored;

final class NoteDeleted implements ShouldBeStored {
  /** @var string */
  public $noteId;

  public function __construct(string $noteId) {
    $this->noteId = $noteId;
  }
}

We need a projector to handle our note events. Create NoteProjector in app/Domain/Note/Projectors:

<?php

namespace App\Domain\Note\Projectors;

use Ramsey\Uuid\Uuid;
use Spatie\EventProjector\Projectors\Projector;
use Spatie\EventProjector\Projectors\ProjectsEvents;
use App\Domain\Note\Events\NoteCreated;
use App\Domain\Note\Events\NoteDeleted;
use App\Models\Note;

class NoteProjector implements Projector {
  use ProjectsEvents;

  public function onNoteCreated(NoteCreated $event, string $aggregateUuid) {
    Note::create([
      'id' => $aggregateUuid,
      'contact_id' => $event->contactId,
      'text' => $event->text,
      'title' => $event->title
    ]);
  }

  public function onNoteDeleted(NoteDeleted $event, string $aggregateUuid) {
    Note::where('id', $aggregateUuid)->delete();
  }
}

To tie all the above together, let's create an aggregate to accept our commands and produce events. Create NoteAggregate in app/Domain/Note:

<?php

namespace App\Domain\Note;

use Spatie\EventProjector\AggregateRoot;
use App\Domain\Note\Events\NoteCreated;
use App\Domain\Note\Events\NoteDeleted;

final class NoteAggregate extends AggregateRoot {
  /** @var string */
  public $title;

  /** @var string */
  public $text;

  /** @var string */
  public $contactId;

  public function createNote(string $contactId, string $title, string $text) {
    $this->recordThat(new NoteCreated($contactId, $title, $text));

    $this->persist();
  }

  public function deleteNote(string $id) {
    $this->recordThat(new NoteDeleted($id));

    $this->persist();
  }

  public function applyNoteCreated(NoteCreated $event) {
    $this->title = $event->title;
    $this->text = $event->text;
    $this->contactId = $event->contactId;

    $this->persist();
  }
}

To ensure our code works, we'll need to write tests for creating and deleting notes.
Create a new test class tests/feature/NoteTest.php and paste these in:

<?php

namespace Tests\Feature;

use Tests\TestCase;
use Illuminate\Foundation\Testing\RefreshDatabase;
use App\Domain\Note\Commands\CreateNote;
use App\Domain\Note\Commands\DeleteNote;
use App\Domain\Contact\Commands\CreateContact;
use App\Models\User;
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->notes()->get()));

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

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

    $command->execute();

    $this->assertEquals(0, count($contact->notes()->get()));
  }
}

Add the following method to TestCase.php class:

protected function getNoteAttributes(Contact $contact) {
      if (empty($this->faker)) $this->faker = Faker::create();

      return [
        'contact_id' => $contact->id,
        'title' => $this->faker->sentence,
        'text' => $this->faker->text
      ];
    }

Now, if you run the tests with vendor/bin/phpunit, all tests should pass.

To complete the backend, let's implement note updates. Like contacts, we'll update note attributes individually.

Add an update handler to the NoteController:

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

    if ($note->contact->user->id == $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);
  }

Update the NoteAggregate with:

public function updateNote(string $noteId, string $title, string $text) {
    $updated = false;

    if (! empty($title) && $title != $this->title) {
      $this->recordThat(new NoteTitleChanged($noteId, $title));

      $updated = true;
    }

    if (! empty($text) && $text != $this->text) {
      $this->recordThat(new NoteTextChanged($noteId, $text));

      $updated = true;
    }

    if ($updated) $this->persist();
  }
  
  public function applyNoteTitleChanged(NoteTitleChanged $event) {
    $this->title = $event->title;

    $this->persist();
  }

  public function applyNoteTextChanged(NoteTextChanged $event) {
    $this->text = $event->text;

    $this->persist();
  }

Update NoteProjector with:

public function onNoteTextChanged(NoteTextChanged $event, string $aggregateUuid) {
    Note::where('id', $aggregateUuid)->update(['text' => $event->text]);
  }

  public function onNoteTitleChanged(NoteTitleChanged $event, string $aggregateUuid) {
    Note::where('id', $aggregateUuid)->update(['title' => $event->title]);
  }

Add the following to your routes/api.php file:

Route::get('contacts/{id}/notes', 'NoteController@index');
Route::post('notes', 'NoteController@store');
Route::put('notes/{id}', 'NoteController@update');
Route::delete('notes/{id}', 'NoteController@destroy');

Add a NoteTitleChanged event:

<?php

namespace App\Domain\Note\Events;
use Spatie\EventProjector\ShouldBeStored;

final class NoteTitleChanged implements ShouldBeStored {
  /** @var string */
  public $title;

  /** @var string */
  public $noteId;

  public function __construct(string $noteId, string $title) {
    $this->noteId = $noteId;
    $this->title = $title;
  }
}

Add a NoteTextChanged event:

<?php

namespace App\Domain\Note\Events;
use Spatie\EventProjector\ShouldBeStored;

final class NoteTextChanged implements ShouldBeStored {
  /** @var string */
  public $text;

  /** @var string */
  public $noteId;

  public function __construct(string $noteId, string $text) {
    $this->noteId = $noteId;
    $this->text = $text;
  }
}

Add an UpdateNote command:

<?php

namespace App\Domain\Note\Commands;

use Ramsey\Uuid\Uuid;
use App\Models\Note;
use App\Domain\Command;
use App\Domain\Note\NoteAggregate;

final class UpdateNote extends Command {
  public function __construct(array $bag) {
    $this->attributes['note_id'] = Uuid::isValid($bag['note_id']) ?
      $bag['note_id'] :
      Uuid::fromString($bag['note_id']);

    if (! empty($bag['title'])) $this->attributes['title'] = $bag['title'];
    if (! empty($bag['text'])) $this->attributes['text'] = $bag['text'];
  }

  public static function from(array $bag) : UpdateNote {
    return new UpdateNote($bag);
  }

  public function isValid() : bool {
    $noteExists = ((bool) Note::find($this->attributes['note_id']));
    // Ignore if not provided
    $titleOk = ! empty($this->attributes['title']) || true;
    $textOk = ! empty($this->attributes['text']) || true;

    return $noteExists && $titleOk && $textOk;
  }

  public function execute() : ?Note {
    if (! $this->isValid()) return null;

    NoteAggregate::retrieve($this->attributes['note_id'])
      ->updateNote(
        $this->attributes['note_id'],
        ! empty($this->attributes['title']) ? $this->attributes['title'] : '',
        ! empty($this->attributes['text']) ? $this->attributes['text'] : ''
      );

    return Note::find($this->attributes['note_id']);
  }
}

Add these new tests to your NoteTest:

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->updated_at, $note->updated_at);
  }

Now, to the front-end.

Run npm i vue-trix to install an editor we'll use for our notes.

Update your Contact.vue component to:

<template>
  <div class="card">
    <div class="card-body">
      <h3 class="text-muted text-center" v-if="!contact">No contact selected</h3>
      <div v-else>
        <contact-form
          ref="form"
          button-text="Update contact"
          :initial-data="form"
          @form-submitted="submit" />
        <hr>
        <notes :contact-id="contact.id" />
      </div>
    </div>
  </div>
</template>

<script>
import ContactForm from './ContactForm';
import Notes from './Notes';

export default {
  name: 'Contact',
  components: {
    ContactForm,
    Notes
  },
  props: {
    contact: {
      type: Object
    }
  },
  data() {
    return {
      form: { name: '', gender: '' }
    };
  },
  watch: {
    contact: {
      deep: true,
      immediate: true,
      handler(c) {
        if (! c) return;

        const { name, gender } = c || { name: '', gender: '' };
        this.form = Object.assign(this.form, { name, gender });

        this.resetForm(this.form);
      }
    }
  },
  methods: {
    submit(form) {
      if (! confirm('Are you sure?')) return;

      axios.put(`/api/contacts/${this.contact.id}`, form)
        .then(({ data: { data } }) => {
          this.$emit('contact-updated', data);

          this.form.name = data.name;
          this.form.gender = data.gender;

          this.resetForm(this.form);
        })
        .catch(({ response }) => {
          alert('Could not create');
        });
    },
    resetForm() {
      if(! this.$refs.form) return;

      this.$refs.form.resetForm(this.form);
    }
  },
}
</script>

Finally, create a Notes.vue component with:

<template>
  <div>
    <p> <span class="h4">Notes</span> &emsp; <button @click="view(null)">Add</button></p>
    <ul v-if="notes.length" class="list-group">
      <li v-for="note in notes" :key="note.id" class="list-group-item d-flex justify-content-between align-items-center">
        {{ note.title }}
        <div class="btn-group btn-group-sm" role="group">
          <button
            type="button"
            class="btn btn-outline-secondary"
            @click.prevent.stop="view(note)">
            Edit
          </button>
          <button
            type="button"
            class="btn btn-outline-danger"
            @click.prevent.stop="deleteNote(note.id)">
            Delete
          </button>
        </div>
      </li>
    </ul>
    <h2 class="text-muted text-center" v-else>No notes</h2>

    <div ref="modal" class="modal" tabindex="-1" role="dialog">
      <div class="modal-dialog modal-lg" role="document">
        <div class="modal-content">
          <div class="modal-header">
            <h5 class="modal-title">{{ modalTitle }}</h5>
            <button type="button" class="close" data-dismiss="modal" aria-label="Close">
              <span aria-hidden="true">&times;</span>
            </button>
          </div>
          <div class="modal-body">
            <form>
              <div class="form-group row">
                <label class="control-label col-sm-12">Title</label>
                <div class="col-sm-12">
                  <input type="text" class="form-control" v-model="note.title">
                </div>
              </div>
              <div class="form-group row">
                <label class="control-label col-sm-12">Text</label>
                <div class="col-sm-12">
                  <vue-trix v-model="note.text" placeholder="Enter note" />
                </div>
              </div>
            </form>
          </div>
          <div class="modal-footer">
            <button
              :disabled="!formOk"
              @click.stop.prevent="save()"
              type="button"
              class="btn btn-secondary"
            >Save</button>
          </div>
        </div>
      </div>
    </div>
  </div>
</template>

<script>
import VueTrix from 'vue-trix';

export default {
  name: 'Notes',
  components: {
    VueTrix
  },
  props: {
    contactId: {
      type: String,
      required: true
    }
  },
  data() {
    return {
      notes: [],
      note: {
        id: '',
        contact_id: this.contactId,
        title: '',
        text: ''
      }
    };
  },
  computed: {
    modalTitle() {
      return !!this.note.id ? 'Update note' : 'Add note';
    },
    titleOk() {
      const { note: { id, title }, notes } = this;

      return !!title && notes.filter(n => n.id !== id).every(n => n.title !== title);
    },
    textOk() {
      return !!this.note.text;
    },
    formOk() {
      return this.titleOk && this.textOk;
    }
  },
  watch: {
    contactId: {
      immediate: true,
      handler() {
        this.fetchNotes();
      }
    }
  },
  methods: {
    fetchNotes() {
      axios.get(`/api/contacts/${this.contactId}/notes`)
        .then(({ data: { data } }) => {
          this.notes = data;
        });
    },
    deleteNote(noteId) {
      if (!confirm('Are you sure?')) return;

      axios.delete(`/api/notes/${noteId}`)
        .then(() => {
          this.notes = this.notes.filter(({ id }) => noteId !== id);
        })
        .catch(() => {
          alert('Could not delete');
        });
    },
    save() {
      this.note.title = this.note.title.trim();
      this.note.text = this.note.text.trim();

      if (this.note.id) {
        axios.put(`/api/notes/${this.note.id}`, this.note)
          .then(({ data: { data } }) => {
            $(this.$refs.modal).modal('hide');

            this.notes = this.notes.map(n => n.id === this.note.id ? data : n);

            this.resetNote();
          })
          .catch(() => {
            alert('Could not update');
          });
      } else {
        axios.post('/api/notes', this.note)
          .then(({ data: { data } }) => {
            this.notes.push(data);

            $(this.$refs.modal).modal('hide');
          })
          .catch(() => {
            alert('Could not create');
          });
      }
    },
    view(note) {
      if (note) {
        this.note = { ...note };
      } else {
        this.resetNote();
      }
      $(this.$refs.modal).modal('show');
    },
    resetNote() {
      this.note.id = '';
      this.note.text = '';
      this.note.title = '';
    }
  }
}
</script>

Now, you can add, edit and delete notes under a contact.

Our contacts have no addresses yet. We'll work on that in the next article.

You can explore all the changes made here.