Website Backend Development with Native PHP
Native PHP without a framework is not archaic. It's a conscious choice when maximum control, minimal overhead is needed, or when a project already exists and changing the stack isn't feasible. PHP 8.2+ with types, attributes, readonly classes, and fibers is a completely different language compared to PHP 5.
Native PHP is justified for: small APIs with predictable functionality, shared hosting without framework installation capability, legacy project migrations in parts, microservices critical for performance.
Entry Point and Routing
Central pattern — single entry point (index.php) with a router:
<?php
// public/index.php
declare(strict_types=1);
require_once __DIR__ . '/../vendor/autoload.php';
use App\Core\Router;
use App\Core\Request;
use App\Core\Response;
use App\Middleware\CorsMiddleware;
use App\Middleware\AuthMiddleware;
$request = Request::fromGlobals();
$router = new Router();
// Route registration
require __DIR__ . '/../routes/api.php';
// Middleware pipeline
$middlewares = [new CorsMiddleware(), new AuthMiddleware()];
$response = (new Pipeline($middlewares))->handle($request, fn($req) => $router->dispatch($req));
$response->send();
<?php
// routes/api.php
use App\Controllers\ProductController;
use App\Controllers\AuthController;
$router->get('/api/v1/products', [ProductController::class, 'index']);
$router->get('/api/v1/products/{id:\d+}', [ProductController::class, 'show']);
$router->post('/api/v1/products', [ProductController::class, 'create'], ['auth', 'admin']);
$router->delete('/api/v1/products/{id}', [ProductController::class, 'destroy'], ['auth', 'admin']);
$router->post('/api/v1/auth/login', [AuthController::class, 'login']);
$router->post('/api/v1/auth/refresh', [AuthController::class, 'refresh']);
Router
<?php
namespace App\Core;
use FastRoute\Dispatcher;
use FastRoute\RouteCollector;
use function FastRoute\simpleDispatcher;
class Router
{
private array $routes = [];
public function get(string $pattern, array $handler, array $middlewares = []): void
{
$this->routes[] = ['GET', $pattern, $handler, $middlewares];
}
public function post(string $pattern, array $handler, array $middlewares = []): void
{
$this->routes[] = ['POST', $pattern, $handler, $middlewares];
}
public function dispatch(Request $request): Response
{
$dispatcher = simpleDispatcher(function (RouteCollector $r) {
foreach ($this->routes as [$method, $pattern, $handler, $middlewares]) {
$r->addRoute($method, $pattern, [$handler, $middlewares]);
}
});
$info = $dispatcher->dispatch($request->method(), $request->path());
return match ($info[0]) {
Dispatcher::FOUND => $this->handleFound($info[1], $info[2], $request),
Dispatcher::NOT_FOUND => Response::json(['error' => 'Not Found'], 404),
Dispatcher::METHOD_NOT_ALLOWED => Response::json(['error' => 'Method Not Allowed'], 405),
};
}
private function handleFound(array $routeInfo, array $params, Request $request): Response
{
[$handler, $middlewares] = $routeInfo;
[$class, $method] = $handler;
// Middleware check
foreach ($middlewares as $middleware) {
$result = app($middleware)->handle($request);
if ($result instanceof Response) return $result;
}
return app($class)->$method($request, $params);
}
}
fastroute/fastroute — the best PHP router by performance, used inside Slim and other frameworks.
PDO and Database Work
<?php
namespace App\Core;
use PDO;
use PDOStatement;
class Database
{
private static ?PDO $instance = null;
public static function getInstance(): PDO
{
if (self::$instance === null) {
$dsn = sprintf('pgsql:host=%s;dbname=%s;port=%s',
getenv('DB_HOST'), getenv('DB_NAME'), getenv('DB_PORT') ?: '5432'
);
self::$instance = new PDO($dsn, getenv('DB_USER'), getenv('DB_PASS'), [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
]);
}
return self::$instance;
}
}
// Minimal Query Builder
class QueryBuilder
{
private PDO $pdo;
private string $table;
private array $conditions = [];
private array $bindings = [];
private ?int $limit = null;
private ?int $offset = null;
public function __construct(string $table)
{
$this->pdo = Database::getInstance();
$this->table = $table;
}
public function where(string $column, mixed $value): static
{
$placeholder = ':' . $column . count($this->bindings);
$this->conditions[] = "{$column} = {$placeholder}";
$this->bindings[$placeholder] = $value;
return $this;
}
public function limit(int $limit): static { $this->limit = $limit; return $this; }
public function offset(int $offset): static { $this->offset = $offset; return $this; }
public function get(): array
{
$sql = "SELECT * FROM {$this->table}";
if ($this->conditions) {
$sql .= ' WHERE ' . implode(' AND ', $this->conditions);
}
if ($this->limit !== null) $sql .= " LIMIT {$this->limit}";
if ($this->offset !== null) $sql .= " OFFSET {$this->offset}";
$stmt = $this->pdo->prepare($sql);
$stmt->execute($this->bindings);
return $stmt->fetchAll();
}
public function insert(array $data): int
{
$columns = implode(', ', array_keys($data));
$placeholders = implode(', ', array_map(fn($k) => ":{$k}", array_keys($data)));
$stmt = $this->pdo->prepare("INSERT INTO {$this->table} ({$columns}) VALUES ({$placeholders})");
$stmt->execute($data);
return (int) $this->pdo->lastInsertId();
}
}
Validation
<?php
namespace App\Core;
class Validator
{
private array $errors = [];
public function validate(array $data, array $rules): bool
{
$this->errors = [];
foreach ($rules as $field => $fieldRules) {
foreach (explode('|', $fieldRules) as $rule) {
$this->applyRule($data, $field, $rule);
}
}
return empty($this->errors);
}
private function applyRule(array $data, string $field, string $rule): void
{
$value = $data[$field] ?? null;
[$ruleName, $param] = array_pad(explode(':', $rule, 2), 2, null);
match ($ruleName) {
'required' => !isset($data[$field]) || $data[$field] === ''
? $this->addError($field, 'required', "Field {$field} is required")
: null,
'min' => is_string($value) && strlen($value) < (int) $param
? $this->addError($field, 'min', "Minimum {$param} characters")
: null,
'max' => is_string($value) && strlen($value) > (int) $param
? $this->addError($field, 'max', "Maximum {$param} characters")
: null,
'numeric' => $value !== null && !is_numeric($value)
? $this->addError($field, 'numeric', "Field {$field} must be a number")
: null,
'email' => $value && !filter_var($value, FILTER_VALIDATE_EMAIL)
? $this->addError($field, 'email', 'Invalid email')
: null,
default => null
};
}
private function addError(string $field, string $rule, string $message): void
{
$this->errors[$field][$rule] = $message;
}
public function getErrors(): array { return $this->errors; }
}
JWT without Libraries for Simple Cases
function createJwt(array $payload, string $secret, int $ttl = 3600): string
{
$header = base64_url_encode(json_encode(['alg' => 'HS256', 'typ' => 'JWT']));
$payload = base64_url_encode(json_encode(array_merge($payload, ['exp' => time() + $ttl])));
$sig = base64_url_encode(hash_hmac('sha256', "{$header}.{$payload}", $secret, true));
return "{$header}.{$payload}.{$sig}";
}
// But better to use firebase/php-jwt for production
When Native PHP Ends
Native approach requires writing infrastructure code yourself — router, DI container, validator, migrations. In practice this means either using composer libraries (fastroute, php-di, phinx) or writing your own. In both cases, as the project grows, a framework starts working — either external (Symfony Components) or your incomplete one.
Profitability boundary for native PHP — projects up to 15–20 endpoints with simple logic. Beyond that, a framework saves time.
Development Timeline
- Basic infrastructure: router, DI, Request/Response — 3–7 days
- Models + PDO + Query Builder — 3–5 days
- Auth + JWT — 2–3 days
- Business logic — 1–3 weeks
Small API or widget for existing website: 2–5 weeks.







