MVCS + Repository Pattern
Laravel API Course
Author: Emad Zaamout
Sunday, May 1, 2021
Download at GithubTable of Contents
- Introduction
- Course Requirements
- Overview
- What is an MVC Pattern (Model View Controller)?
- The problem with the MVC Pattern.
- What is an MVCS (Model View Controller Service)?
- What is the Repository Pattern?
- Environment Setup Requirements.
- Create a new Laravel Project.
- Configure env and env.template.
- Create Database Migrations.
- Create Install and Reset Scripts.
- API Authentication using Sanctum.
- Autoload API Routes.
- Understanding modules.
- Create Sanctum Authorization Route.
- Create Common & Core Modules.
- Create User Module.
- Create Student Modules.
- Create Courses Modules.
- Create Enrollments Module.
Introduction
Hello and Welcome back to another AHT Cloud training series.
Today, we will learn how to build highly scalable, top performance custom APIs using Laravel Framework.
I will be covering all topics and provide you with an advanced architecture that you can use for all your future projects.
Before we get started, dont forget to subscribe to my channel to stay up to date with all my latest training videos.
Course Requirements
In this course, I expect that you have some basic knowledge in Laravel. You should be comfortable creating and running Laravel projects and setting up your local database.
If you dont know how to set up your local environment, I recommend you watch my previous video “Laravel Tutorial Laravel Setup Windows 10 Vagrant Homestead Orcale VM VirtualBox” first. Link is in the description.
In addition, I will be using the following additional tools:
HeidiSQL - Free Windows Database GUI tool.
Postman – Free Cross-platform to make HTTP Requests.
Visual Studio Code – Code Editor.
Overview
Before we get started, I wanted to briefly give you an overview of this course and how its layered out.
The first part, I will be briefly explained what MVC is, MVCS and The Repository Pattern. Then, we will learn how to build highly scalable CRUID APIs using the MVCS and Repository design pattern.
If you dont know what those are, dont worry as we will cover it all in depth.
Its also worth mentioning that we will not be using Eloquent. You can easily use it if you like, it will save you some time. But our goal here is to deliver top performance APIs so we will be using raw SQL. After learning how to do it custom, you can easily use Eloquent with the same structure and design pattern.
What is an MVC Pattern (Model View Controller)?
MVC stands for Model View Controller. It is a software design pattern that lets you separate your application logic into 3 parts:
Model (Data object)
View (User view of the Model, UI)
Controller (Business Logic)
The model is basically your data object. For example, if you have a webpage that displays a list of your users. Then the model would be the list of users.
The view is the user interface. The controller is where you handle all your business logic.
The problem with the MVC Pattern.
The biggest problem with the MVC design pattern is scalability. If you’re literally just using a controller to handle all your backend logic you will bloat your code file making it very it difficult to scale and extend. It will also cost you allot more time to develop and maintain in the long run. Testing will be much difficult.
To resolve all these problems, we will use the MVCS design pattern.
What is an MVCS (Model View Controller Service)?
MVCS, stands for Model View Controller Service. The difference between MVC and MVCS is the addition of the service layer.
The MVCS lets you separate your application logic into 4 parts:
Model (Data object)
View (User view of the Model, UI)
Controller (Business Logic)
The biggest difference of an MVCS and MVC is Instead of writing all your code for your APIs inside your Controller, you write it inside a service file. Your controller should only handle routing your API calls to the correct corresponding service and return the response. Your service file should handle all your logic.
What is the Repository Pattern?
The repository pattern is a design pattern used for abstracting and encapsulating data access. The best way to understand the repository pattern is through an example.
Let’s say we are building an API endpoint to create a new student.
Your front-end code will typically send a POST request to your backend. Then, inside your laravel project, you add the route inside your router file. Then from the router file, we map the POST API endpoint to the corresponding StudentsController. Then the StudentsController, would call the StudentsService.
Inside the StudentsService, you validate the endpoint and insert your data into your database.
So as of now, your request lifecycle looks like this:
Api call -> router file -> StudentsController -> StudentsService
By applying the repository pattern, we will need to add a new layer, which is called repository.
This makes our API lifecycle looks something like this:
Api call -> router file -> StudentsController -> StudentsService -> StudentsRepository
The repository file handles any database related logic. This includes creating the actual records, editing, fetching, or basically running any query.
The repository pattern states that only your service file can communicate with your repository. So, you cannot use the StudentsRepository inside your StudentsController or any other file other than the StudentsService. Additionally, no other service can access StudentsRepository other than the StudentsService. This means, if you have let’s say a service called UsersService and you want to fetch let’s say a student record, then your UsersService will only interact with the StudentsService not the StudentsRepository.
Don’t worry if you don’t understand this yet, once we start coding it will be very easy for you to grasp.
Environment Setup Requirements.
If you already know how to create and set up a new laravel project locally, then feel free to skip this step. If you don’t know how to set up a new Laravel, make sure to watch my previous video. Otherwise, I am running Vagrant Homestead on Windows.
If your using the same set up as me, then you will need to update your Homestead.yaml file.
You will then update your windows hosts files to point your server IP address to a domain name.
I added my homestead.yml below:
---
ip: "192.168.10.10"
memory: 5096
cpus: 4
provider: virtualbox
authorize: ~/.ssh/id_rsa.pub
keys:
- ~/.ssh/id_rsa
networks:
- type: "public_network"
bridge: "Killer E2400 Gigabit Ethernet Controller"
folders:
- map: D:/websites
to: /etc/www/code/
sites:
- map: local.apimastercourse.com
to: /etc/www/code/courses/websockets
php: "8.1"
features:
- mysql: true
- mariadb: false
- postgresql: false
- ohmyzsh: false
- webdriver: false
- rabbitmq: false
- redis-server: false
services:
- enabled:
- "mysql"
ports:
- send: 80
to: 8080
- send: 6001
to: 6001
- send: 33060 # MySQL/MariaDB
to: 3306
- send: 33060 # MySQL/MariaDB
to: 3306
- send: 54320 # postgresql
to: 5432
- send: 2222 # ssh tunnel
to: 22
Create a new Laravel Project.
To create a new Laravel project using composer, run the following command:
composer create-project laravel/laravel websockets
Configure env and env.template.
An env file is a text configuration file that lets you control all your environment constants.
Your .env file is a very important file since it contains allot of credentials and other important information.
If your using git, you should never store this file inside your code repository since if anyone with unauthorized access can steal all your information and access your database and other services.
The .env.example file, is used to keep track of your environment constants.
For the most part, you want to include all your variables here, but you don’t need to set the actual values for them.
This file is safe to store in your code repository since you don’t set any API key or actual secret values.
APP_NAME=apimastercourse
APP_ENV=local
APP_KEY=base64:aN9cSXzaoB5Hd+FbLZz1Nc97nqdtlCuyi0kvRVWHZ7g=
APP_DEBUG=true
APP_URL=http://local.apimastercourse.com
DB_CONNECTION=mysql
DB_HOST=127.0.0.1
DB_PORT=3306
DB_DATABASE=apimastercourse
DB_USERNAME=homestead
DB_PASSWORD=secret
LOG_CHANNEL=stack
LOG_DEPRECATIONS_CHANNEL=null
LOG_LEVEL=debug
BROADCAST_DRIVER=log
CACHE_DRIVER=file
FILESYSTEM_DISK=local
QUEUE_CONNECTION=sync
SESSION_DRIVER=file
SESSION_LIFETIME=120
MEMCACHED_HOST=127.0.0.1
REDIS_HOST=127.0.0.1
REDIS_PASSWORD=null
REDIS_PORT=6379
MAIL_MAILER=smtp
MAIL_HOST=mailhog
MAIL_PORT=1025
MAIL_USERNAME=null
MAIL_PASSWORD=null
MAIL_ENCRYPTION=null
MAIL_FROM_ADDRESS="hello@example.com"
MAIL_FROM_NAME="${APP_NAME}"
AWS_ACCESS_KEY_ID=
AWS_SECRET_ACCESS_KEY=
AWS_DEFAULT_REGION=us-east-1
AWS_BUCKET=
AWS_USE_PATH_STYLE_ENDPOINT=false
PUSHER_APP_ID=
PUSHER_APP_KEY=
PUSHER_APP_SECRET=
PUSHER_APP_CLUSTER=mt1
MIX_PUSHER_APP_KEY="${PUSHER_APP_KEY}"
MIX_PUSHER_APP_CLUSTER="${PUSHER_APP_CLUSTER}"
Create Database Migrations.
Our database design is very simply. We are building an API to allow us to create students, courses and enroll students in courses. So, we have 4 database tables.
To create database tables, we will need to create a new migration. In laravel, we can do that by using the php artisan make:migration command.
For the users, by default Laravel already has a migration for users. So, we will just use that migration file. But I will create another migration called master_seed. This migration file, just provide a basic master level seed for our database.
php artisan make:migration courses
php artisan make:migration students
php artisan make:migration students_courses_enrollments
php artisan make:migration master_seed
Courses table migration:
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up()
{
Schema::create("courses", function (Blueprint $table) {
$table->id();
$table->string("name");
$table->unsignedInteger("capacity");
$table->softDeletes();
$table->timestamps();
});
}
public function down()
{
Schema::dropIfExists('courses');
}
};
Students table migration:
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up()
{
Schema::create('students', function (Blueprint $table) {
$table->id();
$table->string("name");
$table->string("email");
$table->softDeletes();
$table->timestamps();
});
}
public function down()
{
Schema::dropIfExists('students');
}
};
Students courses enrollments table migration:
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up()
{
Schema::create('students_courses_enrollments', function (Blueprint $table) {
$table->id();
$table->unsignedBigInteger("students_id");
$table->unsignedBigInteger("courses_id");
$table->unsignedBigInteger("enrolled_by_users_id");
$table->softDeletes();
$table->timestamps();
$table->foreign("students_id")->references("id")->on("students");
$table->foreign("courses_id")->references("id")->on("courses");
$table->foreign("enrolled_by_users_id")->references("id")->on("users");
});
}
public function down()
{
Schema::dropIfExists('students_courses_enrollments');
}
};
Master seed migration:
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
private array $usersSeedValue = [];
public function __construct()
{
$this->usersSeedValue = [
[
"name" => "Emad",
"email" => "support@ahtcloud.com",
"password" => bcrypt("password"),
"created_at" => now()
]
];
}
public function up()
{
DB::table("users")->insert($this->usersSeedValue);
}
public function down()
{
DB::table("users")->whereIn(
"email",
array_map(
function ($row) {
return $row["email"];
},
$this->usersSeedValue
)
)->delete();
}
};
Create Install and Reset Scripts.
This is an optional step. But I want to always encourage you to always create an install and reset scripts. These scripts will save you allot of time. If you messed up your build, you could easily reset it or reinstall it.
The install script will basically run all the commands that we need to run to install our project on a new computer.
The reset script is to help us locally when we are developing to basically reset everything and start from scratch.
For all your future projects, whatever command you need to run make sure to add it to your scripts.
Create new script "scripts/install.sh"
#!/bin/sh
# chmod u+x YourScript.sh
PATH_BASE="$(dirname "$0")/.."
echo "\nSetting up project ... \n"
echo "\nClearing Cache ... \n"
php artisan clear
php artisan config:clear
php artisan cache:clear
php artisan view:clear
php artisan route:clear
echo "\nInstalling dependencies ... \n"
composer install --no-interaction
# npm install
# create .env if not exists
if [ -f "$PATH_BASE/.env" ]
then
echo "\n.env file already exists.\n"
else
echo "\Creating .env file.\n"
cp .env.example .env
fi
echo "\nDone :)\n"
Create new script "scripts/reset.sh"
#!/bin/sh
# chmod u+x YourScript.sh
echo "\nResetting ... \n"
echo "\nClearing Cache ... \n"
php artisan clear
php artisan config:clear
php artisan cache:clear
php artisan view:clear
php artisan route:clear
echo "\nDropping/recreating database"
php artisan migrate:fresh
echo "\nDone :)\n"
API Authentication using Sanctum.
There are 2 different types of APIs. Ones that are available globally to anyone and the other ones require authentication.
For example, if you’re implementing an API to login, the login API must be available to anyone so there is no authentication required for that.
In our case, we need to secure our students, courses and enrollments API and prevent unauthorized access to it.
We can do this in Laravel easily using Sanctum or Laravel passport to issue API tokens.
In this tutorial, we will use Laravel Sanctum.
The first thing we are going to do is install Sanctum:
composer require laravel/sanctum
Once that’s done we will need to publish the sanctum configuration and migration files using the vendor:publish artisan command.
php artisan vendor:publish --provider="Laravel\Sanctum\SanctumServiceProvider"
Then we will need to run the new migration files
php artisan migrate:fresh
Autoload API Routes.
To better organize our API router file, were going to create a new directory inside "routes/api" called "api". The final folder structure should look like this: "routes/api/".
Update your routes/api.php file:
$files = glob(__DIR__ . "/api/*.php");
foreach ($files as $file) {
require($file);
}
Inside your new "routes/api/" folder, lets go ahead and create our api routes for students, courses and enrollments.
Create the following routes/api/students.php
use App\Http\Controllers\StudentsController;
use Illuminate\Support\Facades\Route;
Route::group(
["middleware" => ["auth:sanctum"]],
function () {
Route::POST("/students", [StudentsController::class, "update"]);
Route::GET("/students/{id}", [StudentsController::class, "get"]);
Route::DELETE("/students/{id}", [StudentsController::class, "softDelete"]);
}
);
Create the following routes/api/sanctum.php
use App\Http\Controllers\SanctumController;
use Illuminate\Support\Facades\Route;
Route::group(
["middleware" => []],
function () {
Route::POST("/sanctum/token", [SanctumController::class, "issueToken"]);
}
);
Create the following routes/api/enrollments.php
use App\Http\Controllers\CoursesController;
use App\Http\Controllers\StudentsCoursesEnrollmentsController;
use Illuminate\Support\Facades\Route;
Route::group(
["middleware" => ["auth:sanctum"]],
function () {
Route::POST("/enrollments", [StudentsCoursesEnrollmentsController::class, "update"]);
Route::GET("/enrollments/{id}", [StudentsCoursesEnrollmentsController::class, "get"]);
Route::DELETE("/enrollments/{id}", [StudentsCoursesEnrollmentsController::class, "softDelete"]);
}
);
Create the following routes/api/courses.php
use App\Http\Controllers\CoursesController;
use Illuminate\Support\Facades\Route;
Route::group(
["middleware" => ["auth:sanctum"]],
function () {
Route::POST("/courses", [CoursesController::class, "update"]);
Route::GET("/courses/{id}", [CoursesController::class, "get"]);
Route::DELETE("/courses/{id}", [CoursesController::class, "softDelete"]);
}
);
Understanding modules.
For our API, each module is basically a feature or represents a database table. For example, Students, Courses, Enrollments and Sanctum would all be inside there own module.
Each module, would typically have the following files: model, mapper, validator, service and repository.
Create a new folder called modules inside app "app/Modules/".
Create Sanctum API Route.
Create a new controller app/Http/Controllers/SanctumController.php
declare(strict_types=1);
namespace App\Http\Controllers;
use App\Modules\Core\HTTPResponseCodes;
use App\Modules\Sanctum\SanctumService;
use Exception;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
class SanctumController
{
private SanctumService $service;
public function __construct(SanctumService $service)
{
$this->service = $service;
}
public function issueToken(Request $request) : Response
{
try {
$dataArray = ($request->toArray() !== [])
? $request->toArray()
: $request->json()->all();
return new Response(
$this->service->issueToken($dataArray),
HTTPResponseCodes::Sucess["code"]
);
} catch (Exception $error) {
return new Response(
[
"exception" => get_class($error),
"errors" => $error->getMessage()
],
HTTPResponseCodes::BadRequest["code"]
);
}
}
}
Create app/Modules/Sanctum/SanctumAuthorizeRequest.php
declare(strict_types=1);
namespace App\Modules\Sanctum;
class SanctumAuthorizeRequest
{
private string $email;
private string $password;
private string $device;
public function __construct(
string $email,
string $password,
string $device
)
{
$this->email = $email;
$this->password = $password;
$this->device = $device;
}
public function getEmail(): string
{
return $this->email;
}
public function getPassword(): string
{
return $this->password;
}
public function getDevice(): string
{
return $this->device;
}
}
Create app/Modules/Sanctum/SanctumAuthorizeRequestMapper.php
declare(strict_types=1);
namespace App\Modules\Sanctum;
class SanctumAuthorizeRequestMapper
{
public static function mapFrom(array $data) : SanctumAuthorizeRequest
{
return new SanctumAuthorizeRequest(
$data["email"],
$data["password"],
$data["device"],
);
}
}
Create app/Modules/Sanctum/SanctumService.php
declare(strict_types=1);
namespace App\Modules\Sanctum;
use App\Models\User;
use Illuminate\Support\Facades\Hash;
use Symfony\Component\HttpFoundation\Exception\BadRequestException;
class SanctumService
{
private SanctumValidator $validator;
public function __construct(SanctumValidator $validator)
{
$this->validator = $validator;
}
public function issueToken(array $rawData) : string
{
$this->validator->validateIssueToken($rawData);
$data = SanctumAuthorizeRequestMapper::mapFrom($rawData);
$user = User::where("email", $data->getEmail())->first();
if (!$user || !Hash::check($data->getPassword(), $user->password)) {
throw new BadRequestException("The provided credentials are incorrect.");
}
return $user->createToken($data->getDevice())->plainTextToken;
}
}
Create app/Modules/Sanctum/SanctumValidator.php
declare(strict_types=1);
namespace App\Modules\Sanctum;
use InvalidArgumentException;
class SanctumValidator
{
public function validateIssueToken(array $rawData) : void
{
$validator = \validator($rawData, [
"email" => "required|email",
"password" => "required|string",
"device" => "required|string"
]);
if ($validator->fails()) {
throw new InvalidArgumentException(json_encode($validator->errors()->all()));
}
}
}
Create Common & Core Modules.
Create app/Modules/Common/MyHelpers.php
declare(strict_types=1);
namespace App\Modules\Common;
abstract class MyHelpers
{
public static function nullStringToInt($str) : ?int
{
if ($str !== null) {
return (int)$str;
}
return null;
}
}
Create app/Modules/Core/HTTPResponseCodes.php
declare(strict_types=1);
namespace App\Modules\Core;
abstract class HTTPResponseCodes
{
const Sucess = [
"title" => "success",
"code" => 200,
"message" => "Request has been successfully processed."
];
const NotFound = [
"title" => "not_found_error",
"code" => 404,
"message" => "Could not locate resource."
];
const InvalidArguments = [
"title" => "invalid_arguments_error",
"code" => 404,
"message" => "Invalid arguments. Server failed to process your request."
];
const BadRequest = [
"title" => "bad_request",
"code" => 400,
"message" => "Server failed to process your request."
];
}
Create User Module.
Create app/Modules/Sanctum/SanctumValidator.php
declare(strict_types=1);
namespace App\Modules\Sanctum;
use InvalidArgumentException;
class SanctumValidator
{
public function validateIssueToken(array $rawData) : void
{
$validator = \validator($rawData, [
"email" => "required|email",
"password" => "required|string",
"device" => "required|string"
]);
if ($validator->fails()) {
throw new InvalidArgumentException(json_encode($validator->errors()->all()));
}
}
}
Create Student Modules.
Create app/Http/Controllers/StudentsController.php
declare(strict_types=1);
namespace App\Http\Controllers;
use App\Modules\Core\HTTPResponseCodes;
use App\Modules\Students\StudentsService;
use Exception;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
class StudentsController
{
private StudentsService $service;
public function __construct(StudentsService $service)
{
$this->service = $service;
}
public function get(int $id) : Response
{
try {
return new response($this->service->get($id)->toArray());
} catch (Exception $error) {
return new Response(
[
"exception" => get_class($error),
"errors" => $error->getMessage()
],
HTTPResponseCodes::BadRequest["code"]
);
}
}
public function update(Request $request): Response
{
try {
$dataArray = ($request->toArray() !== [])
? $request->toArray()
: $request->json()->all();
return new Response(
$this->service->update($dataArray)->toArray(),
HTTPResponseCodes::Sucess["code"]
);
} catch (Exception $error) {
return new Response(
[
"exception" => get_class($error),
"errors" => $error->getMessage()
],
HTTPResponseCodes::BadRequest["code"]
);
}
}
public function softDelete(int $id) : Response
{
try {
return new response($this->service->softDelete($id));
} catch (Exception $error) {
return new Response(
[
"exception" => get_class($error),
"errors" => $error->getMessage()
],
HTTPResponseCodes::BadRequest["code"]
);
}
}
}
Create app/Modules/Students/Students.php
declare(strict_types=1);
namespace App\Modules\Students;
class Students
{
private ?int $id;
private string $name;
private string $email;
private ?string $deletedAt;
private string $createdAt;
private ?string $updatedAt;
function __construct(
?int $id,
string $name,
string $email,
?string $deletedAt,
string $createdAt,
?string $updatedAt
) {
$this->id = $id;
$this->name = $name;
$this->email = $email;
$this->deletedAt = $deletedAt;
$this->createdAt = $createdAt;
$this->updatedAt = $updatedAt;
}
public function toArray(): array {
return [
"id" => $this->id,
"name" => $this->name,
"email" => $this->email,
"deletedAt" => $this->deletedAt,
"createdAt" => $this->createdAt,
"updatedAt" => $this->updatedAt,
];
}
public function toSQL(): array {
return [
"id" => $this->id,
"name" => $this->name,
"email" => $this->email,
"deleted_at" => $this->deletedAt,
"created_at" => $this->createdAt,
"updated_at" => $this->updatedAt,
];
}
public function getId(): ?int
{
return $this->id;
}
public function getName(): string
{
return $this->name;
}
public function getEmail(): string
{
return $this->email;
}
public function getDeletedAt(): ?string
{
return $this->deletedAt;
}
public function getCreatedAt(): string
{
return $this->createdAt;
}
public function getUpdatedAt(): ?string
{
return $this->updatedAt;
}
}
Create app/Modules/Students/StudentsMapper.php
declare(strict_types=1);
namespace App\Modules\Students;
use App\Modules\Common\MyHelpers;
class StudentsMapper
{
public static function mapFrom(array $data): Students
{
return new Students(
MyHelpers::nullStringToInt($data["id"] ?? null),
$data["name"],
$data["email"],
$data["deletedAt"] ?? null,
$data["createdAt"] ?? date("Y-m-d H:i:s"),
$data["updatedAt"] ?? null,
);
}
}
Create app/Modules/Students/Students.php
declare(strict_types=1);
namespace App\Modules\Students;
class StudentsService
{
private StudentsValidator $validator;
private StudentsRepository $repository;
public function __construct(
StudentsValidator $validator,
StudentsRepository $repository
)
{
$this->validator = $validator;
$this->repository = $repository;
}
public function get(int $id): Students
{
return $this->repository->get($id);
}
public function getByCourseId(int $courseId) : array
{
return $this->repository->getByCourseId($courseId);
}
public function update(array $data) : Students
{
$this->validator->validateUpdate($data);
return $this->repository->update(
StudentsMapper::mapFrom($data)
);
}
public function softDelete(int $id): bool
{
return $this->repository->softDelete($id);
}
}
Create app/Modules/Students/StudentsValidator.php
declare(strict_types=1);
namespace App\Modules\Students;
use InvalidArgumentException;
class StudentsValidator
{
public function validateUpdate(array $data): void
{
$validator = validator($data, [
"name" => "required|string",
"email" => "required|string|email",
]);
if ($validator->fails()) {
throw new InvalidArgumentException(json_encode($validator->errors()->all()));
}
}
}
Create app/Modules/Students/StudentsRepository.php
declare(strict_types=1);
namespace App\Modules\Students;
use Illuminate\Support\Facades\DB;
use InvalidArgumentException;
class StudentsRepository
{
private $tableName = "students";
private $selectColumns = [
"students.id",
"students.name",
"students.email",
"students.deleted_at AS deletedAt",
"students.created_at AS createdAt",
"students.updated_at AS updatedAt"
];
public function get(int $id) : Students
{
$selectColumns = implode(", ", $this->selectColumns);
$result = json_decode(json_encode(
DB::selectOne("SELECT $selectColumns
FROM {$this->tableName}
WHERE id = :id AND deleted_at IS NULL
", [
"id" => $id
])
), true);
if ($result === null) {
throw new InvalidArgumentException("Invalid student id.");
}
return StudentsMapper::mapFrom($result);
}
public function update(Students $student): Students
{
return DB::transaction(function () use ($student) {
DB::table($this->tableName)->updateOrInsert([
"id" => $student->getId()
], $student->toSQL());
$id = ($student->getId() === null || $student->getId() === 0)
? (int)DB::getPdo()->lastInsertId()
: $student->getId();
return $this->get($id);
});
}
public function softDelete(int $id): bool
{
$result = DB::table($this->tableName)
->where("id", $id)
->where("deleted_at", null)
->update([
"deleted_at" => date("Y-m-d H:i:s")
]);
if ($result !== 1) {
throw new InvalidArgumentException("Invalid Students Id.");
}
return true;
}
public function getByCourseId(int $courseId): array
{
$selectColumns = implode(", ", $this->selectColumns);
$result = json_decode(json_encode(
DB::select("SELECT $selectColumns
FROM students
JOIN students_courses_enrollments ON students_courses_enrollments.courses_id = :courseId
WHERE students.id = students_courses_enrollments.students_id
AND students_courses_enrollments.deleted_at IS NULL
", [
"courseId" => $courseId
])
), true);
if (count($result) === 0) {
return [];
}
return array_map(function ($row) {
return StudentsMapper::mapFrom($row);
}, $result);
}
}
Create Courses Modules.
Create app/Http/Controllers/CoursesController.php
declare(strict_types=1);
namespace App\Http\Controllers;
use App\Modules\Core\HTTPResponseCodes;
use App\Modules\Courses\CoursesService;
use Exception;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
class CoursesController
{
private CoursesService $service;
public function __construct(CoursesService $service)
{
$this->service = $service;
}
public function get(int $id) : Response
{
try {
return new response($this->service->get($id)->toArray());
} catch (Exception $error) {
return new Response(
[
"exception" => get_class($error),
"errors" => $error->getMessage()
],
HTTPResponseCodes::BadRequest["code"]
);
}
}
public function update(Request $request): Response
{
try {
$dataArray = ($request->toArray() !== [])
? $request->toArray()
: $request->json()->all();
return new Response(
$this->service->update($dataArray)->toArray(),
HTTPResponseCodes::Sucess["code"]
);
} catch (Exception $error) {
return new Response(
[
"exception" => get_class($error),
"errors" => $error->getMessage()
],
HTTPResponseCodes::BadRequest["code"]
);
}
}
public function softDelete(int $id) : Response
{
try {
return new response($this->service->softDelete($id));
} catch (Exception $error) {
return new Response(
[
"exception" => get_class($error),
"errors" => $error->getMessage()
],
HTTPResponseCodes::BadRequest["code"]
);
}
}
}
Create app/Modules/Courses/Courses.php
declare(strict_types=1);
namespace App\Modules\Courses;
class Courses
{
private ?int $id;
private string $name;
private int $totalStudentsEnrolled;
private int $capacity;
private ?string $deletedAt;
private string $createdAt;
private ?string $updatedAt;
function __construct(
?int $id,
string $name,
int $capacity,
int $totalStudentsEnrolled,
?string $deletedAt,
string $createdAt,
?string $updatedAt
) {
$this->id = $id;
$this->name = $name;
$this->capacity = $capacity;
$this->totalStudentsEnrolled = $totalStudentsEnrolled;
$this->deletedAt = $deletedAt;
$this->createdAt = $createdAt;
$this->updatedAt = $updatedAt;
}
public function toArray(): array {
return [
"id" => $this->id,
"name" => $this->name,
"capacity" => $this->capacity,
"totalStudentsEnrolled" => $this->totalStudentsEnrolled,
"deletedAt" => $this->deletedAt,
"createdAt" => $this->createdAt,
"updatedAt" => $this->updatedAt,
];
}
public function toSQL(): array {
return [
"id" => $this->id,
"name" => $this->name,
"capacity" => $this->capacity,
"deleted_at" => $this->deletedAt,
"created_at" => $this->createdAt,
"updated_at" => $this->updatedAt,
];
}
public function getId(): ?int
{
return $this->id;
}
public function getName(): string
{
return $this->name;
}
public function getCapacity(): int
{
return $this->capacity;
}
public function getDeletedAt(): ?string
{
return $this->deletedAt;
}
public function getCreatedAt(): string
{
return $this->createdAt;
}
public function getUpdatedAt(): ?string
{
return $this->updatedAt;
}
public function getTotalStudentsEnrolled(): int
{
return $this->totalStudentsEnrolled;
}
}
Create app/Modules/Courses/CoursesMapper.php
declare(strict_types=1);
namespace App\Modules\Courses;
use App\Modules\Common\MyHelpers;
class CoursesMapper
{
public static function mapFrom(array $data): Courses
{
return new Courses(
MyHelpers::nullStringToInt($data["id"] ?? null),
$data["name"],
$data["capacity"],
$data["totalStudentsEnrolled"] ?? 0,
$data["deletedAt"] ?? null,
$data["createdAt"] ?? date("Y-m-d H:i:s"),
$data["updatedAt"] ?? null,
);
}
}
Create app/Modules/Courses/CoursesRepository.php
declare(strict_types=1);
namespace App\Modules\Courses;
use Illuminate\Support\Facades\DB;
use InvalidArgumentException;
class CoursesRepository
{
private $tableName = "courses";
private $selectColumns = [
"courses.id",
"courses.name",
"courses.capacity",
"(SELECT COUNT(*)
FROM students_courses_enrollments
WHERE students_courses_enrollments.courses_id = courses.id AND students_courses_enrollments.deleted_at IS NULL
)",
"courses.deleted_at AS deletedAt",
"courses.created_at AS createdAt",
"courses.updated_at AS updatedAt"
];
public function get(int $id) : Courses
{
$selectColumns = implode(", ", $this->selectColumns);
$result = json_decode(json_encode(
DB::selectOne("SELECT $selectColumns
FROM {$this->tableName}
WHERE id = :id AND deleted_at IS NULL
", [
"id" => $id
])
), true);
if ($result === null) {
throw new InvalidArgumentException("Invalid courses id.");
}
return CoursesMapper::mapFrom($result);
}
public function update(Courses $student): Courses
{
return DB::transaction(function () use ($student) {
DB::table($this->tableName)->updateOrInsert([
"id" => $student->getId()
], $student->toSQL());
$id = ($student->getId() === null || $student->getId() === 0)
? (int)DB::getPdo()->lastInsertId()
: $student->getId();
return $this->get($id);
});
}
public function softDelete(int $id): bool
{
$result = DB::table($this->tableName)
->where("id", $id)
->where("deleted_at", null)
->update([
"deleted_at" => date("Y-m-d H:i:s")
]);
if ($result !== 1) {
throw new InvalidArgumentException("Invalid courses Id.");
}
return true;
}
}
Create app/Modules/Courses/CoursesValidator.php
declare(strict_types=1);
namespace App\Modules\Courses;
use InvalidArgumentException;
class CoursesValidator
{
public function validateUpdate(array $data): void
{
$validator = validator($data, [
"name" => "required|string",
"capacity" => "required|integer",
]);
if ($validator->fails()) {
throw new InvalidArgumentException(json_encode($validator->errors()->all()));
}
}
}
Create app/Modules/Courses/CoursesService.php
declare(strict_types=1);
namespace App\Modules\Courses;
class CoursesService
{
private CoursesValidator $validator;
private CoursesRepository $repository;
public function __construct(
CoursesValidator $validator,
CoursesRepository $repository
)
{
$this->validator = $validator;
$this->repository = $repository;
}
public function get(int $id): Courses
{
return $this->repository->get($id);
}
public function update(array $data) : Courses
{
$this->validator->validateUpdate($data);
return $this->repository->update(
CoursesMapper::mapFrom($data)
);
}
public function softDelete(int $id): bool
{
return $this->repository->softDelete($id);
}
}
Create Enrollments Module.
Create app/Http/Controllers/StudentsCoursesEnrollmentsController.php
declare(strict_types=1);
namespace App\Http\Controllers;
use App\Modules\Core\HTTPResponseCodes;
use App\Modules\StudentsCoursesEnrollments\StudentsCoursesEnrollmentsService;
use Exception;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
class StudentsCoursesEnrollmentsController
{
private StudentsCoursesEnrollmentsService $service;
public function __construct(StudentsCoursesEnrollmentsService $service)
{
$this->service = $service;
}
public function get(int $id) : Response
{
try {
return new response($this->service->get($id)->toArray());
} catch (Exception $error) {
return new Response(
[
"exception" => get_class($error),
"errors" => $error->getMessage()
],
HTTPResponseCodes::BadRequest["code"]
);
}
}
public function update(Request $request): Response
{
try {
$dataArray = ($request->toArray() !== [])
? $request->toArray()
: $request->json()->all();
return new Response(
$this->service->update($dataArray)->toArray(),
HTTPResponseCodes::Sucess["code"]
);
} catch (Exception $error) {
return new Response(
[
"exception" => get_class($error),
"errors" => $error->getMessage()
],
HTTPResponseCodes::BadRequest["code"]
);
}
}
public function softDelete(int $id) : Response
{
try {
return new response($this->service->softDelete($id));
} catch (Exception $error) {
return new Response(
[
"exception" => get_class($error),
"errors" => $error->getMessage()
],
HTTPResponseCodes::BadRequest["code"]
);
}
}
}
Create app/Modules/StudentsCoursesEnrollments/StudentsCoursesEnrollments.php
declare(strict_types=1);
namespace App\Modules\StudentsCoursesEnrollments;
class StudentsCoursesEnrollments
{
private ?int $id;
private int $studentsId;
private int $coursesId;
private int $enrolledByUsersId;
private ?string $deletedAt;
private string $createdAt;
private ?string $updatedAt;
function __construct(
?int $id,
int $studentsId,
int $coursesId,
int $enrolledByUsersId,
?string $deletedAt,
string $createdAt,
?string $updatedAt
) {
$this->id = $id;
$this->studentsId = $studentsId;
$this->coursesId = $coursesId;
$this->enrolledByUsersId = $enrolledByUsersId;
$this->deletedAt = $deletedAt;
$this->createdAt = $createdAt;
$this->updatedAt = $updatedAt;
}
public function toArray(): array {
return [
"id" => $this->id,
"studentsId" => $this->studentsId,
"coursesId" => $this->coursesId,
"enrolledByUsersId" => $this->enrolledByUsersId,
"deletedAt" => $this->deletedAt,
"createdAt" => $this->createdAt,
"updatedAt" => $this->updatedAt,
];
}
public function toSQL(): array {
return [
"id" => $this->id,
"students_id" => $this->studentsId,
"courses_id" => $this->coursesId,
"enrolled_by_users_id" => $this->enrolledByUsersId,
"deleted_at" => $this->deletedAt,
"created_at" => $this->createdAt,
"updated_at" => $this->updatedAt,
];
}
public function getId(): ?int
{
return $this->id;
}
public function getStudentsId(): int
{
return $this->studentsId;
}
public function getCoursesId(): int
{
return $this->coursesId;
}
public function getEnrolledByUsersId(): int
{
return $this->enrolledByUsersId;
}
public function getDeletedAt(): ?string
{
return $this->deletedAt;
}
public function getCreatedAt(): string
{
return $this->createdAt;
}
public function getUpdatedAt(): ?string
{
return $this->updatedAt;
}
public function getTotalStudentsEnrolled(): int
{
return $this->totalStudentsEnrolled;
}
}
Create app/Modules/StudentsCoursesEnrollments/StudentsCoursesEnrollmentsDatabaseValidator.php
declare(strict_types=1);
namespace App\Modules\StudentsCoursesEnrollments;
use App\Modules\Courses\CoursesService;
use App\Modules\Students\StudentsService;
use InvalidArgumentException;
class StudentsCoursesEnrollmentsDatabaseValidator
{
private CoursesService $coursesService;
private StudentsService $studentsService;
public function __construct(CoursesService $coursesService, StudentsService $studentsService)
{
$this->coursesService = $coursesService;
$this->studentsService = $studentsService;
}
public function validateUpdate(int $coursesId, int $studentsId): void
{
$course = $this->coursesService->get($coursesId);
if ($course->getTotalStudentsEnrolled() >= $course->getCapacity()) {
throw new InvalidArgumentException("Failed to enroll student. Course enrollment limit {$course->getTotalStudentsEnrolled()} reached.");
}
// no duplicates allowed
$studentsEnrolled = $this->studentsService->getByCourseId($coursesId);
for ($i = 0; $i < count($studentsEnrolled); $i++) {
if ($studentsEnrolled[$i]->getId() === $studentsId) {
throw new InvalidArgumentException("Failed to enroll student. Student already registered.");
}
}
}
}
Create app/Modules/StudentsCoursesEnrollments/StudentsCoursesEnrollmentsMapper.php
declare(strict_types=1);
namespace App\Modules\StudentsCoursesEnrollments;
use App\Modules\Common\MyHelpers;
class StudentsCoursesEnrollmentsMapper
{
public static function mapFrom(array $data): StudentsCoursesEnrollments
{
return new StudentsCoursesEnrollments(
MyHelpers::nullStringToInt($data["id"] ?? null),
$data["studentsId"],
$data["coursesId"],
$data["enrolledByUsersId"],
$data["deletedAt"] ?? null,
$data["createdAt"] ?? date("Y-m-d H:i:s"),
$data["updatedAt"] ?? null,
);
}
}
Create app/Modules/StudentsCoursesEnrollments/StudentsCoursesEnrollmentsRepository.php
declare(strict_types=1);
namespace App\Modules\StudentsCoursesEnrollments;
use Illuminate\Support\Facades\DB;
use InvalidArgumentException;
class StudentsCoursesEnrollmentsRepository
{
private $tableName = "students_courses_enrollments";
private $selectColumns = [
"students_courses_enrollments.id",
"students_courses_enrollments.students_id AS studentsId",
"students_courses_enrollments.courses_id AS coursesId",
"students_courses_enrollments.enrolled_by_users_id AS enrolledByUsersId",
"students_courses_enrollments.deleted_at AS deletedAt",
"students_courses_enrollments.created_at AS createdAt",
"students_courses_enrollments.updated_at AS updatedAt"
];
public function get(int $id) : StudentsCoursesEnrollments
{
$selectColumns = implode(", ", $this->selectColumns);
$result = json_decode(json_encode(
DB::selectOne("SELECT $selectColumns
FROM {$this->tableName}
WHERE id = :id AND deleted_at IS NULL
", [
"id" => $id
])
), true);
if ($result === null) {
throw new InvalidArgumentException("Invalid students courses enrollments id.");
}
return StudentsCoursesEnrollmentsMapper::mapFrom($result);
}
public function update(StudentsCoursesEnrollments $student): StudentsCoursesEnrollments
{
return DB::transaction(function () use ($student) {
DB::table($this->tableName)->updateOrInsert([
"id" => $student->getId()
], $student->toSQL());
$id = ($student->getId() === null || $student->getId() === 0)
? (int)DB::getPdo()->lastInsertId()
: $student->getId();
return $this->get($id);
});
}
public function softDelete(int $id): bool
{
$result = DB::table($this->tableName)
->where("id", $id)
->where("deleted_at", null)
->update([
"deleted_at" => date("Y-m-d H:i:s")
]);
if ($result !== 1) {
throw new InvalidArgumentException("Invalid students courses enrollments Id.");
}
return true;
}
}
Create app/Modules/StudentsCoursesEnrollments/StudentsCoursesEnrollmentsService.php
declare(strict_types=1);
namespace App\Modules\StudentsCoursesEnrollments;
use Illuminate\Support\Facades\Auth;
class StudentsCoursesEnrollmentsService
{
private StudentsCoursesEnrollmentsValidator $validator;
private StudentsCoursesEnrollmentsRepository $repository;
public function __construct(
StudentsCoursesEnrollmentsValidator $validator,
StudentsCoursesEnrollmentsRepository $repository
)
{
$this->validator = $validator;
$this->repository = $repository;
}
public function get(int $id): StudentsCoursesEnrollments
{
return $this->repository->get($id);
}
public function update(array $data) : StudentsCoursesEnrollments
{
$data = array_merge(
$data,
[
"enrolledByUsersId" => Auth::user()->id
]
);
$this->validator->validateUpdate($data);
return $this->repository->update(
StudentsCoursesEnrollmentsMapper::mapFrom($data)
);
}
public function softDelete(int $id): bool
{
return $this->repository->softDelete($id);
}
}
Create app/Modules/StudentsCoursesEnrollments/StudentsCoursesEnrollmentsValidator.php
declare(strict_types=1);
namespace App\Modules\StudentsCoursesEnrollments;
use InvalidArgumentException;
class StudentsCoursesEnrollmentsValidator
{
private StudentsCoursesEnrollmentsDatabaseValidator $dbValidator;
public function __construct(StudentsCoursesEnrollmentsDatabaseValidator $dbValidator)
{
$this->dbValidator = $dbValidator;
}
public function validateUpdate(array $data): void
{
$validator = validator($data, [
"studentsId" => "required|int|exists:students,id",
"coursesId" => "required|int|exists:courses,id",
"enrolledByUsersId" => "required|int|exists:users,id",
]);
if ($validator->fails()) {
throw new InvalidArgumentException(json_encode($validator->errors()->all()));
}
$this->dbValidator->validateUpdate($data["coursesId"], $data["studentsId"]);
}
}
Create app/Modules/StudentsCoursesEnrollments/Students.php