In this multi-post series we are creating our own framework. We have a working application complete with routing, controllers, and dependency injection. Let's implement the data layer. We will define the Model of our Model-View-Controller setup.
We will start out with our custom comma-separated file storage for the data. When done, we will use the excellent Doctrine library to re-implement the same interface but this time using a solid, scalable database for storage.
Okay, so at the end of this episode you should have a database with some comments in it. When visiting our /comments page we will render all comments in the database.
I will setup things using SQLite3, but please do adapt to suit your needs. I hear a PostgreSQL backend is nice too. Or perhaps you would prefer a simple SQLite database!
We will begin this episode with defining an interface for persisting your data. That interface will hold no storage layer details so that we can easily change the backend.
Next, we will create a very simple, CSV-based backend for storing our data. Despite its simplicity, it will be fully functional. The lack of any extra dependencies makes it a very lightweight solution too, and perfectly capable for small data sets.
We also want to do something useful, so we will be implementing the CommentsController, adding an index page and a way to add a comment.
Finally, we will re-implement the backend using Doctrine. This is an attempt to show you the power of using dependency injection; the goal is to replace the entire storage layer without a single change in our business layer.
Ok, so since we are using dependency injection, we will first define the interface we want to use for retrieving object. Create an interface with which we can create, read, update and delete CRUD. We will also add an index method:
<?php // src/EntityStorageInterface.phpnamespace Framework\Storage;interface EntityStorageInterface{public function index(string $type): array;public function create(EntityInterface $entity): EntityInterface;public function read(string $type, string $id): EntityInterface;public function update(EntityInterface $entity): EntityInterface;public function delete(EntityInterface $entity): void;}
As you can see we use an EntityInterface so that we can be sure the entity has the properties we require. Specifically, we want it to have functions for getting and setting an id:
<?php // src/Storage/EntityInterface.phpnamespace Framework\Storage;interface EntityInterface {public function setId(string $id);public function getId(): string;}
Okay, with the interfaces setup, let's implement them. Lets start with our entity. It wil implement EntityInterface, and have some getters and setters in addition to that to store the actually useful data.
<?phpnamespace Framework\Entity;use Framework\Storage\EntityInterface;class Comment implements EntityInterface{private string $id;private string $name;private string $email;private string $message;public function setId(string $id) {$this->id = $id;}public function setName(mixed $name){$this->name = $name;}public function setEmail(mixed $email){$this->email = $email;}public function setMessage(mixed $message){$this->message = $message;}public function getId(): string{return $this->id;}public function getName(): string{return $this->name;}public function getEmail(): string{return $this->email;}public function getMessage(): string{return $this->message;}}
In our example we will be using the Comment example, but you can of course create any entity you like. In fact, further down the road we will be implementing a User entity too, also implementing EntityInterface.
With our entity done, we can now fully implement the EntityStorageInterface. We will be implementing just the create and index methods of the interface. We will create placeholder methods for the other functions; thowse will just throw an error for now. Good enough for our example!
<?phpnamespace Framework\Storage;use Exception;use ReflectionClass;use ReflectionProperty;class CsvStorage implements EntityStorageInterface{public function __construct(private readonly string $dir) {}public function index(string $type): array{[$filepath, $properties] = $this->getEntityData(new $type());if (!file_exists($filepath)) {return [];}$file = fopen($filepath, "r");$result = [];while (($line = fgetcsv($file, escape: "\\")) !== false) {$instance = new $type();foreach ($properties as $k => $property) {$property->setValue($instance, $line[$k]);}$result[] = $instance;};return $result;}public function create(EntityInterface $entity): EntityInterface{$entity->setId(uniqid());[$filepath, $properties] = $this->getEntityData($entity);@mkdir(dirname($filepath));$file = fopen($filepath, "ab");fputcsv($file, array_map(fn($property) => $property->getValue($entity), $properties), escape: "\\");return $entity;}public function read(string $type, string $id): EntityInterface { throw new Exception("Not implemented"); }public function update(EntityInterface $entity): EntityInterface { throw new Exception("Not implemented"); }public function delete(EntityInterface $entity): void { throw new Exception("Not implemented"); }/** @return array{filepath: string, properties: array<ReflectionProperty>} */private function getEntityData(EntityInterface $entity): array{$rc = new ReflectionClass($entity);$filepath = "{$this->dir}/{$rc->getShortName()}.csv";$properties = $rc->getProperties(ReflectionProperty::IS_PRIVATE);usort($properties, function(ReflectionProperty $a, ReflectionProperty $b) {if($b->getName() === 'id') {return 1;}if($a->getName() === 'id') {return -1;}return strcasecmp($a->getName(), $b->getName());});return [$filepath, $properties];}}
Actually, there is quite a bit more code than just the implemented functions. Let me explain.
First, there is a __construct function. We use that to input the path to our CSV directory, that is, the directory that will come to hold our CSV files.
Then, there is a getEntityData function. That is a helper function internal to our implementation. We can keep it private because we will not be using it outside of this class. It returns an array containing a) the path we will be storing our CSV file, and b) the array of properties the model has. We sort it so that id is always the first property, and we keep the rest alphabetical.
Now for the actual implementation. That should be relatively straightforward: index retrieves the list of items in the CSV file, one for each row. create will add the item passed as a new row in the CSV file.
With that, we should have created our own storage layer completely from scratch! It can definitely use some more work, getting read, update and delete functions, but hey. This is jsut the start!
Now that we have an interface and an implementation, we can write our code to inject the CsvStorage implementation each time the EntityStorageInterface is requested. So, let's revisit the Application class. We can import the interface and the implementation, and then provide that to our container:
use Framework\Storage\CsvStorage;//...$container = new Container();$container->set(EntityStorageInterface::class, fn() => new CsvStorage(dirname(__DIR__).'/csv-files/'));
With that, the CsvStorage implementation is injected everywhere we request the EntityStorageInterface.
With the data storage layer basically done, we can now write our business logic! We will revisit the CommentController and implement a few functions. We will also add route information.
<?php // src/Controllers/CommentController.php//...class CommentController{#[Route('/comments')]public function index(EntityStorageInterface $store): string {// Render list of items}#[Route('/comments/create')]public function create(): string {// Render the 'create' page with form}#[Route('/comments/post')]public function post(EntityStorageInterface $store): string {// Handle the submitted form}}
As you can see, we inject the storage interface in two of the functions. No need to inject it into the create page, as all that does is render the form.
Let's implement the index function now. That renders a basic list and a button that leads to the create form.
$comments = $store->index(Comment::class);$count = count($store->index(Comment::class));$list = "<ul>" . implode("", array_map(function ($c) {return "<li>". "id:" . htmlspecialchars($c->getId(), ENT_QUOTES) . "<br>". "name:" . htmlspecialchars($c->getName(), ENT_QUOTES) . "<br>". "email:" . htmlspecialchars($c->getEmail(), ENT_QUOTES) . "<br>". "message:" . nl2br(htmlspecialchars($c->getMessage(), ENT_QUOTES)) . "<br>". "</li>";}, $comments)) . "</ul>";return "<h1>Comments</h1><p>Comments found: $count</p><p><a href='/comments/create'>Create new</a></p>$list";
Next up, the create page. Return the form as HTML:
return "<form action='/comments/post' method='post'><label style='display:block;'>Name<input name='name'/></label><label style='display:block;'>Email<input name='email'/></label><label style='display:block;'>Message<textarea name='message'/></textarea></label><button>Create</button></form>";
In the /comments/post route, finally, we save the comment and then return with a simple back to index type link:
$comment = new Comment();$comment->setName($_POST['name']);$comment->setEmail($_POST['email']);$comment->setMessage($_POST['message']);$store->create($comment);return "<h1>Comment created</h1><p><a href='/comments'>to list</a></p>";
Now - if all is implemented without errors, we are done with our CSV storage! Try it out by starting your webserver (I use plain php: php -S localhost:8000 public/index.php). Then, we can navigate to /comments and then start adding comments!
Okay, so we have our very first storage layer working. Nice! Now, to demonstrate how we can easily switch swap the CSV storage for a database-backed storage layer such as doctrine... let's install the sucker!
composer require doctrine/orm doctrine/dbal symfony/cache
Next, configure doctrine in a bootstrap file. I called it doctrine-bootstrap.php. This is pretty much the absolute minimum configuration for doctrine.
<?php // doctrine.php-bootstrap.phpuse Doctrine\DBAL\DriverManager;use Doctrine\ORM\EntityManager;use Doctrine\ORM\ORMSetup;require_once __DIR__."/vendor/autoload.php";$config = ORMSetup::createAttributeMetadataConfig(paths: [__DIR__."/src/Entity"]);$config->enableNativeLazyObjects(true);$connection = DriverManager::getConnection(['driver' => 'pdo_sqlite','path' => __DIR__ . '/db.sqlite',], $config);return new EntityManager($connection, $config);
Refer to the doctrine documentation for more details. Create a front controller for the doctrine schema tool. We can use that to generate the database schema and migrate. The front controller is very basic and just reads the bootstrap file and then run the cli package.
Now --- implementation time! We will create the DoctrineStorage class, implement our EntityStorageInterface once more, and then inject the DoctrineStorage instead of the CsvStorage.
/...class DoctrineStorage implements EntityStorageInterface{private EntityManager $em;public function __construct() {$this->em = require(__DIR__.'/../../doctrine-bootstrap.php');}public function index(string $type): array{return $this->em->getRepository($type)->findAll();}public function create(EntityInterface $entity): EntityInterface{$entity->setId(uniqid());$this->em->persist($entity);$this->em->flush();return $entity;}//...}
As you can see, the doctrine library takes care of all the complex searchin and loading of entities.
We now have a working data layer! We started by defining the EntityStorageInterface and EntityInterface, which keeps us flexible in our choice of storage backend. Next, we implemented a simple but functional CSV-based storage - perfect for small datasets and without any extra dependencies.
After that, we created the Comment entity and gave the CommentController actual functionality to retrieve and create comments. And finally, we demonstrated the power of dependency injection by replacing the entire storage layer with Doctrine ORM, without having to change a single line in our business logic!
If you want to see the complete code, check out the repository at this commit.
We now have a working data layer with database support! But our controllers are still returning simple HTML strings. In the next episode, we'll tackle the V in MVC - the View system. We'll integrate a template engine so we can separate our presentation logic from our business logic, and make our application look really nice!