In our previous article, we implemented note management. In this article, we'll be implementing address creation, deletion and editing. The address types we'll support are phone, email, and physical. We're going to breeze through it faster than we did previously. We'll implement the entire backend first, then the frontend.

Run php artisan make:migration create_addresses_table to create a migration and paste in:

<?php

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

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

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

Create an Address model with:

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;

class Address extends Model {
  use Traits\UsesUuid;

  protected $guarded = [];

  public $incrementing = false;

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

Add the method below to the Contact model:

public function addresses() {
    return $this->hasMany(Address::class);
  }

Create a folder app/Domain/Address/Commands with three command classes: CreateAddress, UpdateAddress and DeleteAddress as shown below:

<?php

namespace App\Domain\Address\Commands;

use Ramsey\Uuid\Uuid;
use App\Domain\Command;
use App\Domain\Address\AddressAggregate;
use App\Models\Address;
use App\Models\Contact;

final class CreateAddress 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['key'] = $bag['key'];
    $this->attributes['value'] = $bag['value'];
  }

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

  public function isValid() : bool {
    return ((bool) Contact::find($this->attributes['contact_id'])) &&
      in_array($this->attributes['key'], ['phone', 'email', 'physical']) &&
      ! empty($this->attributes['value']) &&
      $this->isFormatValid();
  }

  private function isFormatValid() : bool {
    if ($this->attributes['key'] == 'physical') return true; // empty check in isValid will suffice
    if ($this->attributes['key'] == 'phone') return preg_match('/^[+]?\d+$/', $this->attributes['value']);
    if ($this->attributes['key'] == 'email') return preg_match('/^\w+\.*\w*@\w+\.\w+/', $this->attributes['value']);
  }

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

    $newId = Uuid::uuid4();

    AddressAggregate::retrieve($newId)
      ->createAddress($this->attributes['contact_id'], $this->attributes['key'], $this->attributes['value']);

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

namespace App\Domain\Address\Commands;

use Ramsey\Uuid\Uuid;
use App\Models\Address;
use App\Domain\Command;
use App\Domain\Address\AddressAggregate;

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

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

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

  public function isValid() : bool {
    $noteExists = ((bool) Address::find($this->attributes['address_id']));
    // Ignore if not provided
    $keyOk = empty($this->attributes['key']) ? true :
      in_array($this->attributes['key'], ['phone', 'email', 'physical']);
    $valueOk = ! empty($this->attributes['value']) || true;

    return $noteExists && $keyOk && $valueOk && $this->isFormatValid();
  }

  private function isFormatValid() : bool {
    if ($this->attributes['key'] == 'physical') return true; // $valueOk in isValid will suffice
    if ($this->attributes['key'] == 'phone') return preg_match('/^[+]?\d+$/', $this->attributes['value']);
    if ($this->attributes['key'] == 'email') return preg_match('/^\w+\.*\w*@\w+\.\w+/', $this->attributes['value']);
  }

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

    AddressAggregate::retrieve($this->attributes['address_id'])
      ->updateAddress(
        $this->attributes['address_id'],
        ! empty($this->attributes['key']) ? $this->attributes['key'] : '',
        ! empty($this->attributes['value']) ? $this->attributes['value'] : ''
      );

    return Address::find($this->attributes['address_id']);
  }
}
<?php

namespace App\Domain\Address\Commands;

use Ramsey\Uuid\Uuid;
use App\Domain\Command;
use App\Domain\Address\AddressAggregate;
use App\Models\Address;

final class DeleteAddress 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) : DeleteAddress {
    return new DeleteAddress($bag);
  }

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

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

    AddressAggregate::retrieve($this->attributes['id'])->deleteAddress($this->attributes['id']);
  }
}

You might have noticed that isValid() for the create and update commands are different from our previous ones. That's because in addition to checking that non-empty attributes are supplied, we have to verify that value contains a phone number if the key is phone etc.

Create an app/Domain/Address/Events with AddressCreated, AddressDeleted, AddressKeyChanged and AddressValueChanged:

<?php

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

final class AddressCreated implements ShouldBeStored {
  /** @var string */
  public $key;

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

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

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

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

final class AddressDeleted implements ShouldBeStored {
  /** @var string */
  public $addressId;

  public function __construct(string $addressId) {
    $this->addressId = $addressId;
  }
}
<?php

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

final class AddressKeyChanged implements ShouldBeStored {
  /** @var string */
  public $key;

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

  public function __construct(string $addressId, string $key) {
    $this->addressId = $addressId;
    $this->key = $key;
  }
}
<?php

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

final class AddressValueChanged implements ShouldBeStored {
  /** @var string */
  public $value;

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

  public function __construct(string $addressId, string $value) {
    $this->addressId = $addressId;
    $this->value = $value;
  }
}

Create a projector app/Domain/Address/Projectors/AddressProjector.php:

<?php

namespace App\Domain\Address\Projectors;

use Ramsey\Uuid\Uuid;
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::create([
      'id' => $aggregateUuid,
      'contact_id' => $event->contactId,
      'key' => $event->key,
      'value' => $event->value
    ]);
  }

  public function onAddressDeleted(AddressDeleted $event, string $aggregateUuid) {
    Address::where('id', $aggregateUuid)->delete();
  }

  public function onAddressKeyChanged(AddressKeyChanged $event, string $aggregateUuid) {
    Address::where('id', $aggregateUuid)->update(['key' => $event->key]);
  }

  public function onAddressValueChanged(AddressValueChanged $event, string $aggregateUuid) {
    Address::where('id', $aggregateUuid)->update(['value' => $event->value]);
  }
}

Create an aggregate app/Domain/Address/AddressAggregate.php:

<?php

namespace App\Domain\Address;

use Spatie\EventProjector\AggregateRoot;
use App\Domain\Address\Events\AddressCreated;
use App\Domain\Address\Events\AddressDeleted;
use App\Domain\Address\Events\AddressKeyChanged;
use App\Domain\Address\Events\AddressValueChanged;

final class AddressAggregate extends AggregateRoot {
  /** @var string */
  public $key;

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

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

  public function createAddress(string $contactId, string $key, string $value) {
    $this->recordThat(new AddressCreated($contactId, $key, $value));

    $this->persist();
  }

  public function updateAddress(string $addressId, string $key, string $value) {
    $updated = false;

    if (! empty($key) && $key != $this->key) {
      $this->recordThat(new AddressKeyChanged($addressId, $key));

      $updated = true;
    }

    if (! empty($value) && $value != $this->value) {
      $this->recordThat(new AddressValueChanged($addressId, $value));

      $updated = true;
    }

    if ($updated) $this->persist();
  }

  public function deleteAddress(string $id) {
    $this->recordThat(new AddressDeleted($id));

    $this->persist();
  }

  public function applyAddressCreated(AddressCreated $event) {
    $this->key = $event->key;
    $this->value = $event->value;
    $this->contactId = $event->contactId;

    $this->persist();
  }

  public function applyAddressValueChanged(AddressValueChanged $event) {
    $this->value = $event->value;

    $this->persist();
  }

  public function applyAddressKeyChanged(AddressKeyChanged $event) {
    $this->key = $event->key;

    $this->persist();
  }
}

Create a test file AddressTest and paste the following tests:

<?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\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->addresses()->get()));

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

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

    $command->execute();

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

  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());
  }
}

Add the following method to TestCase:

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

      if ($key == 'email') $value = $this->faker->safeEmail;
      if ($key == 'phone') $value = $this->faker->e164PhoneNumber;
      if ($key == 'physical') $value = $this->faker->sentence;

      return [
        'contact_id' => $contact->id,
        'value' => $value,
        'key' => $key
      ];
    }

All the tests should be green when you run it.

Moving on to the frontend, let's create a new controller AddressController:

<?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()->contacts()->where('id', $id)->first();

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

    return response([
      'success' => true,
      'data' => $contact->addresses()->get()->jsonSerialize()
    ], 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->jsonSerialize()
    ], Response::HTTP_CREATED);
  }

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

    if ($address->contact->user->id == $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::findOrFail($id);

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

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

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

Add the following routes to routes/api.php:

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

Update the 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" />
        <hr>
        <addresses :contact-id="contact.id" />
      </div>
    </div>
  </div>
</template>

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

export default {
  name: 'Contact',
  components: {
    ContactForm,
    Notes,
    Addresses
  },
  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>

Add an Addresses.vue component and paste this into it:

<template>
  <div>
    <p><span class="h4">Addresses</span> &emsp; <button @click="view(null)">Add</button></p>
    <ul v-if="addresses.length" class="list-group">
      <li v-for="address in addresses" :key="address.id" class="list-group-item d-flex justify-content-between align-items-center">
        <span class="badge badge-secondary">{{ address.key | keyLabel }}</span>
        {{ address.value }}
        <div class="btn-group btn-group-sm" role="group">
          <button
            type="button"
            class="btn btn-outline-secondary"
            @click.prevent.stop="view(address)">
            Edit
          </button>
          <button
            type="button"
            class="btn btn-outline-danger"
            @click.prevent.stop="deleteAddress(address.id)">
            Delete
          </button>
        </div>
      </li>
    </ul>
    <h2 class="text-muted text-center" v-else>No addresses</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">Type</label>
                <div class="col-sm-12">
                  <select v-model="address.key" id="" class="form-control">
                    <option value="phone">Phone</option>
                    <option value="email">Email</option>
                    <option value="physical">Address</option>
                  </select>
                </div>
              </div>
              <div class="form-group row">
                <label class="control-label col-sm-12">Value</label>
                <div class="col-sm-12">
                  <input type="text" class="form-control" v-model="address.value" :placeholder="placeholder">
                </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>
export default {
  name: 'Addresses',
  props: {
    contactId: {
      type: String,
      required: true
    }
  },
  data() {
    return {
      addresses: [],
      address: {
        id: '',
        contact_id: this.contactId,
        key: 'phone',
        value: ''
      }
    };
  },
  computed: {
    modalTitle() {
      return !!this.address.id ? 'Update contact detail' : 'Add contact detail';
    },
    placeholder() {
      if (this.address.key === 'phone') return 'Enter phone number';

      if (this.address.key === 'email') return 'Enter email address';

      if (this.address.key === 'physical') return 'Enter address';
    },
    keyOk() {
      return ['email', 'phone', 'physical'].indexOf(this.address.key) !== -1;
    },
    valueOk() {
      const { address: { id, value, key }, addresses } = this;

      return !!value &&
        addresses
          .filter(a => a.id !== id && a.key === key)
          .every(a => a.value !== value);
    },
    formatOk() {
      if (this.address.key === 'email') return /^\w+\.*\w*@\w+\.\w+/.test(this.address.value);

      if (this.address.key === 'phone') return /^[+]?\d+$/.test(this.address.value);

      if (this.address.key === 'physical') return !!this.address.value;
    },
    formOk() {
      return this.keyOk && this.valueOk && this.formatOk;
    }
  },
  watch: {
    contactId: {
      immediate: true,
      handler() {
        this.fetchAddresses();
      }
    }
  },
  filters: {
    keyLabel(key) {
      if (key === 'physical') return 'Address';

      return `${key[0].toUpperCase()}${key.substr(1)}`;
    }
  },
  methods: {
    fetchAddresses() {
      axios.get(`/api/contacts/${this.contactId}/addresses`)
        .then(({ data: { data } }) => {
          this.addresses = data;
        });
    },
    deleteAddress(addressId) {
      if (!confirm('Are you sure?')) return;

      axios.delete(`/api/addresses/${addressId}`)
        .then(() => {
          this.addresses = this.addresses.filter(({ id }) => addressId !== id);
        })
        .catch(() => {
          alert('Could not delete');
        });
    },
    save() {
      this.address.key = this.address.key.trim();
      this.address.value = this.address.value.trim();

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

            this.addresses = this.addresses.map(a => a.id === this.address.id ? data : a);

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

            $(this.$refs.modal).modal('hide');
          })
          .catch(() => {
            alert('Could not create');
          });
      }
    },
    view(address) {
      if (address) {
        this.address = { ...address };
      } else {
        this.resetAddress();
      }
      $(this.$refs.modal).modal('show');
    },
    resetAddress() {
      this.address.id = '';
      this.address.value = '';
      this.address.key = 'phone';
    }
  }
}
</script>

That's all for our address feature.

You can view all changes here.