Skip to content

Backend Architecture - PHP Phalcon

Overview

This document outlines the backend architecture, API design, and best practices for our SaaS CRM application built with PHP Phalcon framework.

Technology Stack

Core Technologies

Technology Version Purpose
PHP 8.2+ Programming language
Phalcon 5.x High-performance PHP framework
MySQL 8.0+ Primary database
Redis 7.x Caching and sessions
Elasticsearch 8.x Full-text search
RabbitMQ 3.x Message queue

Additional Libraries

{
  "require": {
    "php": ">=8.2",
    "ext-phalcon": "~5.0",
    "firebase/php-jwt": "^6.0",
    "guzzlehttp/guzzle": "^7.0",
    "monolog/monolog": "^3.0",
    "phpmailer/phpmailer": "^6.0",
    "predis/predis": "^2.0",
    "elasticsearch/elasticsearch": "^8.0"
  }
}

Project Structure

Directory Layout

phalcon-app/
├── app/
│   ├── config/           # Configuration files
│   │   ├── config.php
│   │   ├── loader.php
│   │   ├── routes.php
│   │   └── services.php
│   ├── controllers/      # API controllers
│   │   ├── BaseController.php
│   │   ├── AuthController.php
│   │   └── CustomerController.php
│   ├── models/           # Database models
│   │   ├── Users.php
│   │   ├── Customers.php
│   │   └── Orders.php
│   ├── services/         # Business logic
│   │   ├── AuthService.php
│   │   ├── CustomerService.php
│   │   └── EmailService.php
│   ├── repositories/     # Data access layer
│   │   ├── UserRepository.php
│   │   └── CustomerRepository.php
│   ├── validators/       # Input validation
│   │   ├── AuthValidator.php
│   │   └── CustomerValidator.php
│   ├── middleware/       # Request middleware
│   │   ├── AuthMiddleware.php
│   │   ├── CorsMiddleware.php
│   │   └── RateLimitMiddleware.php
│   ├── library/          # Custom libraries
│   │   ├── JWT.php
│   │   └── Helpers.php
│   ├── migrations/       # Database migrations
│   └── tasks/           # CLI tasks
├── public/
│   └── index.php        # Entry point
├── storage/
│   ├── cache/           # File cache
│   ├── logs/            # Application logs
│   └── uploads/         # User uploads
├── tests/
│   ├── unit/
│   └── integration/
├── vendor/              # Composer dependencies
└── composer.json        # PHP dependencies

MVC Architecture

Controller Implementation

<?php
// app/controllers/CustomerController.php

namespace App\Controllers;

use Phalcon\Mvc\Controller;
use Phalcon\Http\Response;
use App\Services\CustomerService;
use App\Validators\CustomerValidator;
use App\Models\Customers;

class CustomerController extends BaseController
{
    private CustomerService $customerService;
    private CustomerValidator $validator;

    public function initialize()
    {
        parent::initialize();
        $this->customerService = new CustomerService();
        $this->validator = new CustomerValidator();
    }

    /**
     * GET /api/customers
     * List all customers with pagination
     */
    public function indexAction()
    {
        try {
            $page = $this->request->getQuery('page', 'int', 1);
            $limit = $this->request->getQuery('limit', 'int', 20);
            $search = $this->request->getQuery('search', 'string', '');
            $sort = $this->request->getQuery('sort', 'string', 'created_at');
            $order = $this->request->getQuery('order', 'string', 'desc');

            $result = $this->customerService->getCustomers([
                'page' => $page,
                'limit' => $limit,
                'search' => $search,
                'sort' => $sort,
                'order' => $order
            ]);

            return $this->successResponse($result);
        } catch (\Exception $e) {
            return $this->errorResponse($e->getMessage(), 500);
        }
    }

    /**
     * GET /api/customers/{id}
     * Get single customer
     */
    public function showAction($id)
    {
        try {
            $customer = $this->customerService->getCustomerById($id);

            if (!$customer) {
                return $this->errorResponse('Customer not found', 404);
            }

            return $this->successResponse($customer);
        } catch (\Exception $e) {
            return $this->errorResponse($e->getMessage(), 500);
        }
    }

    /**
     * POST /api/customers
     * Create new customer
     */
    public function createAction()
    {
        try {
            $data = $this->request->getJsonRawBody(true);

            // Validate input
            $validation = $this->validator->validateCreate($data);
            if ($validation->failed()) {
                return $this->errorResponse($validation->getErrors(), 422);
            }

            $customer = $this->customerService->createCustomer($data);

            return $this->successResponse($customer, 201);
        } catch (\Exception $e) {
            return $this->errorResponse($e->getMessage(), 500);
        }
    }

    /**
     * PUT /api/customers/{id}
     * Update customer
     */
    public function updateAction($id)
    {
        try {
            $data = $this->request->getJsonRawBody(true);

            // Validate input
            $validation = $this->validator->validateUpdate($data);
            if ($validation->failed()) {
                return $this->errorResponse($validation->getErrors(), 422);
            }

            $customer = $this->customerService->updateCustomer($id, $data);

            if (!$customer) {
                return $this->errorResponse('Customer not found', 404);
            }

            return $this->successResponse($customer);
        } catch (\Exception $e) {
            return $this->errorResponse($e->getMessage(), 500);
        }
    }

    /**
     * DELETE /api/customers/{id}
     * Delete customer
     */
    public function deleteAction($id)
    {
        try {
            $result = $this->customerService->deleteCustomer($id);

            if (!$result) {
                return $this->errorResponse('Customer not found', 404);
            }

            return $this->successResponse(['message' => 'Customer deleted successfully']);
        } catch (\Exception $e) {
            return $this->errorResponse($e->getMessage(), 500);
        }
    }
}

Model Implementation

<?php
// app/models/Customers.php

namespace App\Models;

use Phalcon\Mvc\Model;
use Phalcon\Mvc\Model\Behavior\Timestampable;
use Phalcon\Validation;
use Phalcon\Validation\Validator\Email;
use Phalcon\Validation\Validator\Uniqueness;

class Customers extends Model
{
    public $id;
    public $name;
    public $email;
    public $phone;
    public $company;
    public $status;
    public $created_at;
    public $updated_at;
    public $deleted_at;

    public function initialize()
    {
        $this->setSource('customers');

        // Relationships
        $this->hasMany('id', Orders::class, 'customer_id', [
            'alias' => 'orders'
        ]);

        $this->hasMany('id', Notes::class, 'customer_id', [
            'alias' => 'notes'
        ]);

        // Behaviors
        $this->addBehavior(
            new Timestampable([
                'beforeCreate' => [
                    'field' => 'created_at',
                    'format' => 'Y-m-d H:i:s'
                ],
                'beforeUpdate' => [
                    'field' => 'updated_at',
                    'format' => 'Y-m-d H:i:s'
                ]
            ])
        );
    }

    public function validation()
    {
        $validator = new Validation();

        $validator->add('email', new Email([
            'message' => 'Invalid email format'
        ]));

        $validator->add('email', new Uniqueness([
            'message' => 'Email already exists'
        ]));

        return $this->validate($validator);
    }

    // Soft delete
    public function beforeDelete()
    {
        $this->deleted_at = date('Y-m-d H:i:s');
        $this->save();
        return false; // Prevent actual deletion
    }

    // Scopes
    public static function active()
    {
        return self::find([
            'conditions' => 'deleted_at IS NULL AND status = :status:',
            'bind' => ['status' => 'active']
        ]);
    }

    // Accessors
    public function getFullInfo()
    {
        return [
            'id' => $this->id,
            'name' => $this->name,
            'email' => $this->email,
            'company' => $this->company,
            'orders_count' => $this->orders->count(),
            'total_revenue' => $this->calculateTotalRevenue()
        ];
    }

    private function calculateTotalRevenue()
    {
        return $this->orders->sum(function($order) {
            return $order->total;
        });
    }
}

Service Layer

<?php
// app/services/CustomerService.php

namespace App\Services;

use App\Models\Customers;
use App\Repositories\CustomerRepository;
use Phalcon\Di\Injectable;
use Phalcon\Cache\Cache;

class CustomerService extends Injectable
{
    private CustomerRepository $repository;
    private Cache $cache;

    public function __construct()
    {
        $this->repository = new CustomerRepository();
        $this->cache = $this->getDI()->get('cache');
    }

    public function getCustomers(array $params)
    {
        $cacheKey = 'customers:' . md5(json_encode($params));

        // Check cache
        $cached = $this->cache->get($cacheKey);
        if ($cached !== null) {
            return $cached;
        }

        // Build query
        $builder = $this->modelsManager->createBuilder()
            ->from(Customers::class)
            ->where('deleted_at IS NULL');

        // Search
        if (!empty($params['search'])) {
            $builder->andWhere(
                'name LIKE :search: OR email LIKE :search: OR company LIKE :search:',
                ['search' => '%' . $params['search'] . '%']
            );
        }

        // Sorting
        $builder->orderBy($params['sort'] . ' ' . $params['order']);

        // Pagination
        $paginator = new \Phalcon\Paginator\Adapter\QueryBuilder([
            'builder' => $builder,
            'limit' => $params['limit'],
            'page' => $params['page']
        ]);

        $page = $paginator->paginate();

        $result = [
            'data' => $page->items->toArray(),
            'meta' => [
                'total' => $page->total_items,
                'per_page' => $page->limit,
                'current_page' => $page->current,
                'last_page' => $page->last,
                'from' => ($page->current - 1) * $page->limit + 1,
                'to' => min($page->current * $page->limit, $page->total_items)
            ]
        ];

        // Cache for 5 minutes
        $this->cache->set($cacheKey, $result, 300);

        return $result;
    }

    public function createCustomer(array $data)
    {
        $customer = new Customers();
        $customer->assign($data);

        if (!$customer->save()) {
            throw new \Exception(implode(', ', $customer->getMessages()));
        }

        // Clear cache
        $this->clearCustomerCache();

        // Send welcome email
        $this->eventsManager->fire('customer:created', $customer);

        return $customer->toArray();
    }

    public function updateCustomer($id, array $data)
    {
        $customer = Customers::findFirst($id);

        if (!$customer) {
            return null;
        }

        $customer->assign($data);

        if (!$customer->save()) {
            throw new \Exception(implode(', ', $customer->getMessages()));
        }

        // Clear cache
        $this->clearCustomerCache();
        $this->cache->delete('customer:' . $id);

        return $customer->toArray();
    }

    private function clearCustomerCache()
    {
        $keys = $this->cache->queryKeys('customers:*');
        foreach ($keys as $key) {
            $this->cache->delete($key);
        }
    }
}

API Design

RESTful Endpoints

# Customer endpoints
GET    /api/customers          # List customers
GET    /api/customers/{id}     # Get customer
POST   /api/customers          # Create customer
PUT    /api/customers/{id}     # Update customer
DELETE /api/customers/{id}     # Delete customer

# Nested resources
GET    /api/customers/{id}/orders     # Customer orders
GET    /api/customers/{id}/notes      # Customer notes
POST   /api/customers/{id}/notes      # Add note

# Bulk operations
POST   /api/customers/bulk-import    # Import customers
POST   /api/customers/bulk-delete    # Delete multiple
POST   /api/customers/bulk-update    # Update multiple

# Actions
POST   /api/customers/{id}/activate   # Activate customer
POST   /api/customers/{id}/deactivate # Deactivate customer
POST   /api/customers/{id}/merge      # Merge customers

Request/Response Format

Request Example

{
  "name": "John Doe",
  "email": "john@example.com",
  "phone": "+1234567890",
  "company": "Acme Corp",
  "address": {
    "street": "123 Main St",
    "city": "New York",
    "state": "NY",
    "zip": "10001"
  },
  "tags": ["vip", "enterprise"]
}

Success Response

{
  "success": true,
  "data": {
    "id": 123,
    "name": "John Doe",
    "email": "john@example.com",
    "created_at": "2025-01-15T10:00:00Z"
  },
  "meta": {
    "timestamp": "2025-01-15T10:00:00Z",
    "version": "v1"
  }
}

Error Response

{
  "success": false,
  "error": {
    "code": "VALIDATION_ERROR",
    "message": "Validation failed",
    "details": {
      "email": ["Email already exists"],
      "phone": ["Invalid phone format"]
    }
  },
  "meta": {
    "timestamp": "2025-01-15T10:00:00Z",
    "request_id": "req_123456"
  }
}

Authentication & Authorization

JWT Implementation

<?php
// app/services/AuthService.php

namespace App\Services;

use Firebase\JWT\JWT;
use Firebase\JWT\Key;
use App\Models\Users;

class AuthService
{
    private string $secretKey;
    private string $algorithm = 'HS256';
    private int $tokenLifetime = 3600; // 1 hour

    public function __construct()
    {
        $this->secretKey = $_ENV['JWT_SECRET'];
    }

    public function login(string $email, string $password)
    {
        $user = Users::findFirst([
            'conditions' => 'email = :email:',
            'bind' => ['email' => $email]
        ]);

        if (!$user || !password_verify($password, $user->password)) {
            throw new \Exception('Invalid credentials');
        }

        $token = $this->generateToken($user);
        $refreshToken = $this->generateRefreshToken($user);

        // Store refresh token
        $user->refresh_token = $refreshToken;
        $user->save();

        return [
            'access_token' => $token,
            'refresh_token' => $refreshToken,
            'expires_in' => $this->tokenLifetime,
            'user' => $user->toArray()
        ];
    }

    private function generateToken(Users $user)
    {
        $payload = [
            'iss' => $_ENV['APP_URL'],
            'sub' => $user->id,
            'email' => $user->email,
            'role' => $user->role,
            'permissions' => $user->getPermissions(),
            'iat' => time(),
            'exp' => time() + $this->tokenLifetime
        ];

        return JWT::encode($payload, $this->secretKey, $this->algorithm);
    }

    public function validateToken(string $token)
    {
        try {
            $decoded = JWT::decode($token, new Key($this->secretKey, $this->algorithm));
            return (array) $decoded;
        } catch (\Exception $e) {
            throw new \Exception('Invalid token');
        }
    }
}

Middleware Implementation

<?php
// app/middleware/AuthMiddleware.php

namespace App\Middleware;

use Phalcon\Mvc\Micro\MiddlewareInterface;
use Phalcon\Mvc\Micro;
use App\Services\AuthService;

class AuthMiddleware implements MiddlewareInterface
{
    private AuthService $authService;

    public function __construct()
    {
        $this->authService = new AuthService();
    }

    public function call(Micro $app)
    {
        $request = $app->request;
        $response = $app->response;

        // Skip auth for public routes
        $publicRoutes = ['/api/auth/login', '/api/auth/register', '/api/health'];
        if (in_array($request->getURI(), $publicRoutes)) {
            return true;
        }

        // Get token from header
        $authHeader = $request->getHeader('Authorization');
        if (!$authHeader) {
            $response->setStatusCode(401);
            $response->setJsonContent([
                'success' => false,
                'error' => 'No authorization token provided'
            ]);
            return false;
        }

        // Validate token
        try {
            $token = str_replace('Bearer ', '', $authHeader);
            $payload = $this->authService->validateToken($token);

            // Store user data for later use
            $app->user = $payload;

            return true;
        } catch (\Exception $e) {
            $response->setStatusCode(401);
            $response->setJsonContent([
                'success' => false,
                'error' => $e->getMessage()
            ]);
            return false;
        }
    }
}

Database Design

Migration System

<?php
// app/migrations/1.0.0/customers.php

use Phalcon\Db\Column;
use Phalcon\Db\Index;
use Phalcon\Db\Reference;
use Phalcon\Migrations\Mvc\Model\Migration;

class CustomersMigration_100 extends Migration
{
    public function morph()
    {
        $this->morphTable('customers', [
            'columns' => [
                new Column('id', [
                    'type' => Column::TYPE_INTEGER,
                    'unsigned' => true,
                    'notNull' => true,
                    'autoIncrement' => true,
                    'size' => 11,
                    'first' => true
                ]),
                new Column('name', [
                    'type' => Column::TYPE_VARCHAR,
                    'notNull' => true,
                    'size' => 255
                ]),
                new Column('email', [
                    'type' => Column::TYPE_VARCHAR,
                    'notNull' => true,
                    'size' => 255
                ]),
                new Column('phone', [
                    'type' => Column::TYPE_VARCHAR,
                    'size' => 20
                ]),
                new Column('company', [
                    'type' => Column::TYPE_VARCHAR,
                    'size' => 255
                ]),
                new Column('status', [
                    'type' => Column::TYPE_ENUM,
                    'values' => ['active', 'inactive', 'pending'],
                    'default' => 'active'
                ]),
                new Column('created_at', [
                    'type' => Column::TYPE_DATETIME,
                    'notNull' => true
                ]),
                new Column('updated_at', [
                    'type' => Column::TYPE_DATETIME
                ]),
                new Column('deleted_at', [
                    'type' => Column::TYPE_DATETIME
                ])
            ],
            'indexes' => [
                new Index('PRIMARY', ['id'], 'PRIMARY'),
                new Index('idx_email', ['email'], 'UNIQUE'),
                new Index('idx_status', ['status']),
                new Index('idx_deleted', ['deleted_at'])
            ]
        ]);
    }
}

Performance Optimization

Caching Strategy

<?php
// app/config/cache.php

return [
    'redis' => [
        'adapter' => 'Redis',
        'options' => [
            'host' => $_ENV['REDIS_HOST'],
            'port' => $_ENV['REDIS_PORT'],
            'auth' => $_ENV['REDIS_PASSWORD'],
            'persistent' => false,
            'index' => 0,
            'prefix' => 'crm_',
            'lifetime' => 3600
        ]
    ],
    'strategies' => [
        'customers' => 300,      // 5 minutes
        'users' => 600,          // 10 minutes
        'permissions' => 3600,   // 1 hour
        'config' => 86400        // 24 hours
    ]
];

Query Optimization

// Use query builder for complex queries
$customers = $this->modelsManager->createBuilder()
    ->columns([
        'c.id',
        'c.name',
        'c.email',
        'COUNT(o.id) as order_count',
        'SUM(o.total) as total_revenue'
    ])
    ->from(['c' => Customers::class])
    ->leftJoin(Orders::class, 'o.customer_id = c.id', 'o')
    ->where('c.deleted_at IS NULL')
    ->groupBy('c.id')
    ->having('order_count > :min:', ['min' => 5])
    ->orderBy('total_revenue DESC')
    ->limit(100)
    ->getQuery()
    ->execute();

Error Handling

Global Error Handler

<?php
// app/config/services.php

$di->setShared('errorHandler', function() {
    return new \App\Library\ErrorHandler();
});

// app/library/ErrorHandler.php
class ErrorHandler
{
    public function handle(\Throwable $exception)
    {
        $logger = Di::getDefault()->get('logger');

        // Log error
        $logger->error($exception->getMessage(), [
            'file' => $exception->getFile(),
            'line' => $exception->getLine(),
            'trace' => $exception->getTraceAsString()
        ]);

        // Determine response
        $code = $exception->getCode() ?: 500;
        $message = $_ENV['APP_DEBUG'] ? $exception->getMessage() : 'Internal Server Error';

        return [
            'success' => false,
            'error' => [
                'code' => $code,
                'message' => $message,
                'request_id' => uniqid('req_')
            ]
        ];
    }
}

Testing

Unit Test Example

<?php
// tests/unit/CustomerServiceTest.php

use PHPUnit\Framework\TestCase;
use App\Services\CustomerService;
use App\Models\Customers;

class CustomerServiceTest extends TestCase
{
    private CustomerService $service;

    protected function setUp(): void
    {
        $this->service = new CustomerService();
    }

    public function testCreateCustomer()
    {
        $data = [
            'name' => 'Test Customer',
            'email' => 'test@example.com',
            'phone' => '1234567890'
        ];

        $customer = $this->service->createCustomer($data);

        $this->assertIsArray($customer);
        $this->assertEquals('Test Customer', $customer['name']);
        $this->assertEquals('test@example.com', $customer['email']);
    }

    public function testCreateCustomerWithInvalidEmail()
    {
        $this->expectException(\Exception::class);

        $data = [
            'name' => 'Test Customer',
            'email' => 'invalid-email'
        ];

        $this->service->createCustomer($data);
    }
}

Last Updated: January 2025 Version: 1.0.0