Let's make a simple application with Apie!

 


It's time to show how you can make a simple web application in Apie. Most of the time web applications are built around MVC. And like any technique MVC has some downsides as well. It's often not very friendly for domain-driven design, which results in many web developers understanding domain-driven design incorrectly (see my article about things people get wrong about domain-driven design).

Apie is all about Domain-Driven Design first. It follows a variation of Naked Objects architecture. You make the domain objects and Apie handles the logic to make an admin panel or a Rest API. All the cross-cutting concerns and complexity of the infrastructure is handled for you, so a developer can focus on the domain objects which are the heart of your application.

With Apie I don't need to think about how to store it in the database, making a full text search or have to learn OpenAPI specifications etc.

Apie domain objects have some nice properties since they are framework agnostic and storage agnostic. I can move them from one application into an other application and they will work. I can even switch frameworks without effort. Also switching from a CMS to a Rest API also requires very little time compared to MVC where it requires a complete rewrite.

So let's see how that would work in practice. Remember that Apie is still in development, so it's far from production ready or prone for changes.

Prerequisites

All you need is a computer with PHP 8.1 or newer with the recommended php extensions and have composer installed for package management. Of course git is also required. Preferably you also have a working nginx/apache server or the Symfony CLI installed.

The application we are going to build

Our demo application will be a collector's checklist where people can see if their (geeky) collection is complete or which one are missing. An example of a similar application would be on the website https://www.brickset.com.



It's a website for Lego enthusiasts and if you log in, you can mark which Lego sets or minifigs you own. Of course our application could also be used for other (geeky) collection, like Playmobil sets, Barbie dolls, or your caught Pokémon. It does not matter for our application.

Preparation

We are going to design our application with domain objects first by first making a list of domain objects we are going to need. Domain objects are grouped by bounded context. Because our application is small we only have one bounded context we call it 'collection'. We also identify our domain objects:
  • User: for logging in. You have a admin or collector role.
  • Collectable: anything that can be collected
  • Collected registration: Information about a user that collected something.


Installation

Time to start. I have made a composer skeleton project you can use to start with a project with Apie. See my article how I made this skeleton project.

In a (Linux) terminal we create our test project:
composer create-project apie/apie-project-starter app -s dev

This installs some composer packages and will ask you how to setup the application. 


We install the maximum setup and use a default user. Laravel and Symfony are both fine as framework. Symfony has a faster kernel and routing system and Apie is more optimized in Symfony, but Laravel comes with a console command to start a dev server. Symfony requires Symfony Cli to have a dev server.

Once we are ready run ./artisan serve if you chose Laravel or run symfony server:start if you picked Symfony and have Symfony cli installed. It will start the application on some arbitrary port. In a browser you can get there with http://localhost:<port assigned>/ 

Set up bounded context

If we chose Symfony as framework open config/packages/apie.yaml.
If we chose Laravel as framework open config/apie.php

We change the example bounded context into the collection bounded context:

apie:
  cms:
    dashboard_template: 'apie/dashboard.html.twig'
  doctrine:
    connection_params:
      driver: pdo_sqlite
      path: "%kernel.project_dir%/db.sqlite"
  datalayers:
    default_datalayer: 'Apie\DoctrineEntityDatalayer\DoctrineEntityDatalayer'
  bounded_contexts:
    collection:
      entities_folder: "%kernel.project_dir%/src/Apie/Collection/Resources/"
      entities_namespace: 'App\Apie\Collection\Resources'
      actions_folder: "%kernel.project_dir%/src/Apie/Collection/Actions/"
      actions_namespace: 'App\Apie\Collection\Actions'
We can also move the example classes to Apie\Collection namespace, to have our user object. Feel free to delete ExampleDto, Example and VerifyOtp class.

You might need some renames of 'example' into 'collection' to see a working admin panel and Rest API with OpenAPI specification.
The Rest API would be on http://localhost:<port>/api/collection and the CMS on http://localhost:<port>/cms/collection

Of course you can skip it all and just go to https://github.com/pjordaan/apie-demo-example download the example locally.

Creating the domain objects

You can create your domain objects in src/Apie/Collection/Resources and they will appear if written correctly. For simplification we have a console command made to make them. If you picked Symfony as framework:
bin/console apie:create-domain-object Collectable
bin/console apie:create-domain-object CollectedRegistration
Or if picked Laravel as a framework:
./artisan apie:create-domain-object Collectable
./artisan apie:create-domain-object CollectedRegistration

Collectable class

Now the most 'fun' object of our little application. We have the following business logic:
  • Only admins can create and edit collectables. 
  • Collectables can not be removed.
  • Collectables can have a list of unique tags. No tags is also allowed
  • Collectables have a name for display that should not be empty.
  • Collectables have a single picture. Any file is accepted as picture
  • Collectables require a name and picture
So with Apie this ends up as our domain object:

<?php

namespace App\Apie\Collection\Resources;

use Apie\Core\Attributes\AllowMultipart;
use Apie\Core\Attributes\HasRole;
use Apie\Core\Attributes\RuntimeCheck;
use Apie\Core\Attributes\StaticCheck;
use Apie\Core\Entities\EntityInterface;
use App\Apie\Collection\Identifiers\CollectableIdentifier;
use App\Apie\Collection\Lists\TagSet;
use App\Apie\Collection\ValueObjects\CollectableName;
use Psr\Http\Message\UploadedFileInterface;

#[AllowMultipart]
class Collectable implements EntityInterface
{
    #[RuntimeCheck(new HasRole('admin'))]
    public function __construct(
        private CollectableIdentifier $id,
        private CollectableName $name,
        private UploadedFileInterface $picture,
        private TagSet $tags
    ) {
    }

    public function getId(): CollectableIdentifier
    {
        return $this->id;
    }

    #[RuntimeCheck(new HasRole('admin'))]
    public function setName(CollectableName $name)
    {
        $this->name = $name;
    }

    public function getName(): CollectableName
    {
        return $this->name;
    }

    #[RuntimeCheck(new HasRole('admin'))]
    public function setTags(TagSet $tags)
    {
        $this->tags = $tags;
    }

    public function getTags(): TagSet
    {
        return $this->tags;
    }

    #[RuntimeCheck(new HasRole('admin'))]
    public function setPicture(UploadedFileInterface $picture)
    {
        $this->picture = $picture;
    }

    public function getPicture(): UploadedFileInterface
    {
        return $this->picture;
    }
}

PHP8 attributes are used to indicate business logic for authorization. We have StaticCheck and RuntimeCheck. If StaticCheck fails a URL will never be created. If RuntimeCheck fails a URL exists, but the user will receive a 403 Access Denied on the URL or the getter will not be used on calling the URL. HasRole is a class that checks if in the current context you are logged in. Internally it has a small appliesContext method that returns true or false. You can make your own classes for specific business logic restrictions.

The CollectableIdentifier typehint is a value object with metadata telling you it references a Collectable entity. It sounds like overkill at first, but it will make more sense if you start handling relations between different domain objects.

We see every setter has the RuntimeCheck if you have the admin role. If we miss one, we do get a edit button, but can only modify that value if we are not an admin user.
The AllowMultipart attribute is not required, but sadly we need it for now to indicate that the resource has a file upload. Without AllowMultipart file uploads would work, but sending the file is less efficient as we can only safely submit it base64 encoded. If you check the API specs you will notice it has an extra endpoint to download the picture. The API will also return the file upload as a link to the download url.

The tags are written as a TagSet class. Basically in Apie you have 3 types of lists: lists, hashmaps and sets. I also wrote an article about why PHP arrays are sometimes too limited to communicate what they contain. We don't want the same Tag more than once on a collectable, so in this case we need it as a set:
<?php
namespace App\Apie\Collection\Lists;

use Apie\Core\Identifiers\Identifier;
use Apie\Core\Lists\ItemSet;

final class TagSet extends ItemSet
{
    public function offsetGet(mixed $offset): Identifier
    {
        return parent::offsetGet($offset);
    }
}
Instead of using string we use value objects. The name of a collectable has constraints (for example it can not be empty and it has a limited maximum length) and because of that we require a value object to validate it against things like empty strings.
<?php
namespace App\Apie\Collection\ValueObjects;

use Apie\Core\Attributes\FakeMethod;
use Apie\Core\ValueObjects\Interfaces\HasRegexValueObjectInterface;
use Apie\Core\ValueObjects\IsStringWithRegexValueObject;
use Faker\Generator;

#[FakeMethod('createRandom')]
final class CollectableName implements HasRegexValueObjectInterface
{
    use IsStringWithRegexValueObject;

    public static function getRegularExpression(): string
    {
        return '/^\w[\s\w]{0,60}\w$/';
    }

    protected function convert(string $input): string
    {
        return trim($input);
    }

    public static function createRandom(Generator $faker): self
    {
        if ($faker->boolean()) {
            return new self($faker->word());
        }
        if ($faker->boolean()) {
            return new self($faker->colorName() . ' ' . $faker->randomElement(['Pikachu', 'Spongebob', 'Peppa Pig']));
        }
        return new self($faker->word() . ' ' . $faker->word());
    }
}
A value object contains all business logic what a valid value for a collectable name is. It also sanitizes input and we can use it with apie/faker to seed our database by telling how to make a fake collectable name. Unlike MVC where this logic is split everywhere, all the logic is in our value object.

We can not remove Collectables. If we want to be able to remove collectable we require to add RemovalCheck attributes on the domain object.

User class

The user class is a very common object in a web application. Sadly because every application is different every application requires its own User object with its own business logic. This is our business logic:
  • The first admin user is generated with a console command
  • Only admins can create users
  • A user can be disabled and has a reason field why the user is disabled
  • A user can log in if it entered a valid password and is not disabled
  • A user always has one role: admin or collector
  • A user will be logged out if disabled or deleted from the database when logged in.
  • An admin can see all users, a collector can only see its own user account.
So our user object becomes this:

<?php
namespace App\Apie\Collection\Resources;

use Apie\Common\Interfaces\CheckLoginStatusInterface;
use Apie\Common\Interfaces\HasRolesInterface;
use Apie\CommonValueObjects\Email;
use Apie\Core\Attributes\HasRole;
use Apie\Core\Attributes\Internal;
use Apie\Core\Attributes\LoggedIn;
use Apie\Core\Attributes\RemovalCheck;
use Apie\Core\Attributes\RuntimeCheck;
use Apie\Core\Attributes\StaticCheck;
use Apie\Core\Entities\EntityWithStatesInterface;
use Apie\Core\Lists\PermissionList;
use Apie\Core\Lists\StringList;
use Apie\Core\Permissions\PermissionInterface;
use Apie\Core\Permissions\RequiresPermissionsInterface;
use Apie\Serializer\Exceptions\ValidationException;
use Apie\Core\ValueObjects\DatabaseText;
use Apie\TextValueObjects\EncryptedPassword;
use Apie\TextValueObjects\StrongPassword;
use App\Apie\Collection\Enums\UserRole;
use App\Apie\Collection\Identifiers\UserId;
use LogicException;

#[RuntimeCheck(new LoggedIn())]
#[RemovalCheck(new StaticCheck())]
#[RemovalCheck(new RuntimeCheck(new HasRole('admin')))]
final class User implements EntityWithStatesInterface, CheckLoginStatusInterface, HasRolesInterface, PermissionInterface, RequiresPermissionsInterface
{
    private UserId $id;
    private EncryptedPassword $password;
    private ?DatabaseText $blockedReason = null;
    private UserRole $role = UserRole::Collector;

    #[RuntimeCheck(new HasRole('admin'))]
    public function __construct(
        private Email $email,
        StrongPassword $password
    ) {
        $this->id = UserId::fromNative($email);
        $this->password = EncryptedPassword::fromUnencryptedPassword($password);
    }

    public function getId(): UserId
    {
        return $this->id;
    }

    public function getEmail(): Email
    {
        return $this->email;
    }

    public function isDisabled(): bool
    {
        return $this->blockedReason !== null;
    }

    #[RuntimeCheck(new HasRole('admin'))]
    public function getBlockedReason(): ?DatabaseText
    {
        return $this->blockedReason;
    }

    private function checkUnblocked(string $field): void
    {
        if ($this->blockedReason !== null) {
            throw ValidationException::createFromArray([
                $field => new LogicException('User "' . $this->email . '" is blocked!')
            ]);
        }
    }

    #[RuntimeCheck(new HasRole('admin'))]
    public function block(DatabaseText $blockedReason): User
    {
        $this->checkUnblocked('blockedReason');
        $this->blockedReason = $blockedReason;

        return $this;
    }

    #[RuntimeCheck(new HasRole('admin'))]
    public function unblock(): User
    {
        if ($this->blockedReason === null) {
            throw new LogicException('User "' . $this->email . '" is not blocked!');
        }
        $this->blockedReason = null;

        return $this;
    }

    #[Internal]
    public function provideAllowedMethods(): StringList
    {
        return new StringList(
            $this->isDisabled() ? ['unblock'] : ['block']
        );
    }

    #[RuntimeCheck(new HasRole('admin'))]
    public function verifyPassword(string $password): bool
    {
        $this->checkUnblocked('password');
        return $this->password->verifyUnencryptedPassword($password);
    }

    #[Internal]
    public function getRoles(): StringList
    {
        return new StringList([$this->role->value]);
    }

    #[Internal]
    public function getPermissionIdentifiers(): PermissionList
    {
        if ($this->role === UserRole::Admin) {
            return new PermissionList(['admin', 'user:' . $this->id]);
        }
        return new PermissionList(['user:' . $this->id]);
    }

    #[Internal]
    public function getRequiredPermissions(): PermissionList
    {
        return new PermissionList(['user:' . $this->id->toNative(), 'admin']);
    }

    public function getRole(): UserRole
    {
        return $this->role;
    }

    #[RuntimeCheck(new HasRole('admin'))]
    public function setRole(UserRole $role): void
    {
        $this->role = $role;
    }
}
Our user has 5 interfaces for a reason and is clearly the most complicated domain object in our application:
  • EntityWithStatesInterface: used for the blocked/not blocked status. If you are blocked you will only get the unblock action and viceversa.
  • CheckLoginStatusInterface: adds a isDisabled method that logs out a user if it returns true.
  • HasRolesInterface: to make the HasRole attribute work on all our domain objects.
  • PermissionInterface: returns the list of allowed permissions to be able to make an access control list.
  • RequiresPermissionsInterface: adds access control list to the list of users. To see the user in the list you need to have the 'admin' permission or the 'user:<user id>' permission to see yourself.
All methods with the Internal attribute are hidden from the outside. Without it you would get extra fields on the GET or extra endpoints in the API to run these methods.
This user uses the password value objects I have written an article about.
A User also has some entity actions possible which can be reflected in the OpenAPI spec:

Or in the CMS user interface (which uses the EntityWithStatesInterface to return only applicable actions):

To login we require an Apie action which can be seen a Remote Procedure Call mapped to the Rest API or Admin Panel as a form. Login works by convention over configuration and requires the method name to be verifyAuthentication:
<?php

namespace App\Apie\Collection\Actions;

use Apie\Common\ApieFacade;
use Apie\Core\Attributes\Not;
use Apie\Core\Attributes\Requires;
use Apie\Core\Attributes\RuntimeCheck;
use Apie\Core\BoundedContext\BoundedContextId;
use Apie\Core\Exceptions\EntityNotFoundException;
use App\Apie\Collection\Identifiers\UserId;
use App\Apie\Collection\Resources\User;

class Authentication
{
    public function __construct(private readonly ApieFacade $apie)
    {
    }

    #[RuntimeCheck(new Not(new Requires('authenticated')))]
    public function verifyAuthentication(UserId $username, string $password): ?User
    {
        try {
            $user = $this->apie->find($username, new BoundedContextId('collection'));
        } catch (EntityNotFoundException) {
            return null;
        }
        if ($user instanceof User && !$user->isDisabled()) {
            return $user->verifyPassword($password) ? $user : null;
        }
        

        return null; // @phpstan-ignore-line
    }
}
This is how it looks like in the API call:
And in the CMS:


We should throw an error or return null for not logging in. Apie does not care what object you return here. If you would return a Collectable instance, then you would be logged in as a Collectable instance.

We also need a special action for creating the first admin user:
<?php
namespace App\Apie\Collection\Actions;

use Apie\CommonValueObjects\Email;
use Apie\Core\Attributes\Context;
use Apie\Core\Attributes\Requires;
use Apie\Core\Attributes\StaticCheck;
use Apie\Core\Datalayers\ApieDatalayer;
use Apie\TextValueObjects\StrongPassword;
use App\Apie\Collection\Enums\UserRole;
use App\Apie\Collection\Resources\User;

final class AdminTools
{
    #[StaticCheck(new Requires('console'))]
    public static function createAdminUser(
        #[Context()]
        ApieDatalayer $datalayer,
        Email $email
    ): string {
        $password = StrongPassword::createRandom();
        $user = new User(
            $email,
            $password
        );
        $user->setRole(UserRole::Admin);
        $datalayer->persistNew($user);
        return 'Admin user created. Generated password' . PHP_EOL . $password->toNative();
    }
}
Because we use StaticCheck here no public URL is created in the API and CMS and we get a console only command. We can run this interactively very easily, but it is not available in the API or CMS. If we would remove the StaticCheck it would be a public API call/CMS action and will show a result page with return value of the action.

In a previous article I covered the origin of the apie/console package which explains how I made the package.

Collectable registration

Our last domain object remembers what you collected and how many you have. We establish some business logic here:
  • You can only create instances linked to the authenticated user.
  • An admin can see all records, a collector can only see the ones he created.
  • You can not enter an amount lower than or equal to 0.
<?php
namespace App\Apie\Collection\Resources;

use Apie\Core\Attributes\Context;
use Apie\Core\Attributes\HasRole;
use Apie\Core\Attributes\Internal;
use Apie\Core\Attributes\LoggedIn;
use Apie\Core\Attributes\RemovalCheck;
use Apie\Core\Attributes\RuntimeCheck;
use Apie\Core\Attributes\StaticCheck;
use Apie\Core\Entities\EntityInterface;
use Apie\Core\Lists\PermissionList;
use Apie\Core\Permissions\RequiresPermissionsInterface;
use App\Apie\Collection\Identifiers\CollectableIdentifier;
use App\Apie\Collection\Identifiers\CollectedRegistrationIdentifier;
use App\Apie\Collection\Identifiers\UserId;
use App\Apie\Collection\ValueObjects\CollectableAmount;

#[RuntimeCheck(new LoggedIn())]
#[RemovalCheck(new StaticCheck())]
#[RemovalCheck(new RuntimeCheck())]
class CollectedRegistration implements EntityInterface, RequiresPermissionsInterface
{
    private CollectedRegistrationIdentifier $id;

    private UserId $userId;

    public function __construct(
        #[Context('authenticated')]
        User $user,
        private CollectableIdentifier $collectable,
        private CollectableAmount $amount
    ) {
        $this->userId = $user->getId();
        $this->id = CollectedRegistrationIdentifier::createRandom();
    }

    public function setAmount(CollectableAmount $amount)
    {
        $this->amount = $amount;
    }

    public function getId(): CollectedRegistrationIdentifier
    {
        return $this->id;
    }

    #[RuntimeCheck(new HasRole('admin'))]
    public function getUserId(): UserId
    {
        return $this->userId;
    }

    public function getCollectable(): CollectableIdentifier
    {
        return $this->collectable;
    }

    public function getAmount(): CollectableAmount
    {
        return $this->amount;
    }

    #[Internal]
    public function getRequiredPermissions(): PermissionList
    {
        return new PermissionList(['user:' . $this->userId, 'admin']);
    }
}

As you can see it uses the value objects UserId and CollectableIdentifier to tell we reference an other domain object. In domain-driven design you are not supposed to link domain objects directly, unless you are a root aggregate.
But the typehint also provides Apie to know you are not a root aggregate but are actually linking to an other domain object.

We implement RequiresPermissionsInterface so we can indicate to filter results per user. This way an admin can see all of them and a collector can only see his own collection.

We can not typehint amount as integer, because we only want positive integers. So for that reason we make a value object:
<?php
namespace App\Apie\Collection\ValueObjects;

use Apie\Core\Attributes\FakeMethod;
use Apie\Core\Attributes\SchemaMethod;
use Apie\Core\ValueObjects\Interfaces\ValueObjectInterface;
use Apie\Core\ValueObjects\Utils;

#[SchemaMethod('createSchema')]
#[FakeMethod('createRandom')]
final class CollectableAmount implements ValueObjectInterface
{
    public function __construct(private readonly int $amount)
    {
        if ($amount <= 0) {
            throw new \UnexpectedValueException('Value "' . $amount . '" is lower or equal than 0');
        }
    }

    public static function fromNative(mixed $input): CollectableAmount
    {
        return new static(Utils::toInt($input));
    }

    public function toNative(): int
    {
        return $this->amount;
    }

    public static function createRandom(): self
    {
        return new self(random_int(1, 12));
    }

    public static function createSchema(): array
    {
        return [
            'type' => 'int',
            'minimum' => 1,
        ];
    }
}
And with that we are done!

Conclusion

Making this example application took me actually shorter than writing this article. It's quite hard to explain an architecture in a single article. 

My main focus now in developing Apie is trying to improve the CMS User interface and customize the CMS a little bit more and Apie will be production ready.

The biggest benefit is that many technical infrastructure requirements are handled by Apie, so you can focus entirely on domain-driven design! And this means the business logic is only in one place, which means it's easier to communicate business logic with other teams/new developers. In domain driven design this is called 'ubiquitous language' and one of the main advantages of proper domain-driven design.

Feel free to test out the example code at https://github.com/pjordaan/apie-demo-example 

Comments