MVCS + Repository Pattern

Laravel API Course

Author: Emad Zaamout

Sunday, May 1, 2021

Download at Github

Table of Contents

  1. Introduction
  2. Course Requirements
  3. Overview
  4. What is an MVC Pattern (Model View Controller)?
  5. The problem with the MVC Pattern.
  6. What is an MVCS (Model View Controller Service)?
  7. What is the Repository Pattern?
  8. Environment Setup Requirements.
  9. Create a new Laravel Project.
  10. Configure env and env.template.
  11. Create Database Migrations.
  12. Create Install and Reset Scripts.
  13. API Authentication using Sanctum.
  14. Autoload API Routes.
  15. Understanding modules.
  16. Create Sanctum Authorization Route.
  17. Create Common & Core Modules.
  18. Create User Module.
  19. Create Student Modules.
  20. Create Courses Modules.
  21. 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:

  1. Model (Data object)

  2. View (User view of the Model, UI)

  3. 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:

  1. Model (Data object)

  2. View (User view of the Model, UI)

  3. 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



        

Other Posts

GIT Crash Course using Bitbucket By Emad Zaamout

Saturday May 1, 2021

Laravel Websockets Example Chat Application

Author: Emad Zaamout
GIT Crash Course using Bitbucket By Emad Zaamout

Saturday May 1, 2021

Laravel API Course | MVCS Repository Pattern

Author: Emad Zaamout
GIT Crash Course using Bitbucket By Emad Zaamout

Saturday October 24, 2021

Git Tutorial - Git Crash Course using BitBucket

Author: Emad Zaamout
What is AWS Elastic Load Balancer By Emad Zaamout

Monday October 18, 2021

AWS Elastic Load Balancing

Author: Emad Zaamout
DMARC SPF DKIM Course By Emad Zaamout

Saturday October 16, 2021

Email DNS Master Course - SPF + DKIM + DMARC

Author: Emad Zaamout
Email SPF Record Tutorial – Sender Policy Framework (SPF) | Prevent Email Spoofing | DNS Course By Emad Zaamout

Saturday October 16, 2021

Email SPF Record Tutorial – Sender Policy Framework (SPF) | Prevent Email Spoofing | DNS Course

Author: Emad Zaamout
DMARC Tutorial - How to set up DNS DMARC record | Protect Your Doman By Emad Zaamout

Saturday October 16, 2021

DMARC Tutorial - How to set up DNS DMARC record | Protect Your Doman

Author: Emad Zaamout
Git Hooks Crash Course

Sunday, September, 2021 (MDT)

Git Hooks Crash Course

Author: Emad Zaamout
Laravel CI\CD using AWS RDS EC2 S3 CodeDeploy BitBucket By Emad Zaamout

Friday, September 17, 2021 (MDT)

Laravel DevOps Tutorial - Laravel Deployment Automation CI\CD using AWS RDS EC2 S3 CodeDeploy BitBucket

Author: Emad Zaamout
Deploy any Laravel app in AWS (Amazon Web Services) By Emad Zaamout

Monday, April 19, 2021 (MDT)

Deploy any Laravel App in AWS (Amazon Web Services)

Author: Emad Zaamout
Fisher Yates Shuffle Algorithm Implementation? By Emad Zaamout

Saturday, September 26, 2020 (MDT)

Find out the secrets, tips and tricks to ranking number 1 on Google.

Author: Emad Zaamout
Fisher Yates Shuffle Algorithm Implementation? By Emad Zaamout

Saturday, September 26, 2020 (MDT)

Fisher - Yates Shuffle Algorithm Implementation

Author: Emad Zaamout
What Is an Ecommerce Website & How to Get Started (2020 guide)? By Emad Zaamout

Saturday, September 26, 2020 (MDT)

What Is an Ecommerce Website & How to Get Started (2020 guide)?

Author: Emad Zaamout
5 Reasons Why You Need A Website Calgary Website Design Company AHT Cloud

Thursday, May 7, 2020

5 Reasons Why You Need A Website

Author: Emad Zaamout
Whats Involved in Creating a Unique Custom Website? By Emad Zaamout

Thursday, May 7, 2020

Whats Involved in Creating a Unique Custom Website?

Author: Emad Zaamout
SEO Checklist By Emad Zaamout

Thursday, May 7, 2020

SEO CHECKLIST

Author: Emad Zaamout

GET YOUR FREE ESTIMATE

CONTACT US TODAY FOR YOUR FREE CONSULTATION!


Contact us today to discuss your goals and we will create a simple roadmap to get you there. We look forward to speaking with you!

Main Office

Phone:   1 587-834-6567
Email:   support@ahtcloud.com
32 Westwinds Crescent NE #130
Calgary, AB T3J 5L3, CA


Products

TMS
Cloud Based Transportation Management System


https://www.ahttms.com
https://www.cloud.ahttms.com

Hours Of Operation

Monday 8:00 am - 5:00 pm
Tuesday 8:00 am - 5:00 pm
Wednesday 8:00 am - 5:00 pm
Thursday 8:00 am - 5:00 pm
Friday 8:00 am - 5:00 pm
Saturday Closed
Sunday Closed