Skip to content

Instantly share code, notes, and snippets.

@bplaat
Created December 30, 2021 17:27
Show Gist options
  • Select an option

  • Save bplaat/314c577cc378a7c4e50e02ae48b1c105 to your computer and use it in GitHub Desktop.

Select an option

Save bplaat/314c577cc378a7c4e50e02ae48b1c105 to your computer and use it in GitHub Desktop.
Yet another single file PHP MVC framework
RewriteEngine On
RewriteCond %{REQUEST_FILENAME} !-f
RewriteRule ^.*$ index.php [L]
<?php
// ### BassieMVC v0.2.0 ###
// A simple small single file PHP MVC framework which looks like Laravel
define('ROOT', dirname(__DIR__));
// Checklist:
// Database [X]
// Model [X]
// View [X]
// Utils:
// - config [X]
// - dd [X]
// - abort [X]
// Router [X]
// Middleware [ ]
// Validation [ ]
// Auth [ ]
// ##########################################################
// ###################### DATABASE ##########################
// ##########################################################
// Simple PDO wrapper class
class DB {
protected static ?PDO $pdo = null;
protected static int $queryCount = 0;
public static function connect(string $dsn, string $user, string $password): void
{
static::$pdo = new PDO($dsn, $user, $password, [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_OBJ,
PDO::ATTR_EMULATE_PREPARES => false
]);
}
public static function queryCount(): int
{
return static::$queryCount;
}
public static function lastInsertId(): int
{
return static::$pdo->lastInsertId();
}
public static function query(string $query, array $parameters = []): PDOStatement
{
$statement = static::$pdo->prepare($query);
$statement->execute($parameters);
static::$queryCount++;
return $statement;
}
}
// ##########################################################
// ######################## VIEW ############################
// ##########################################################
// Simple PHP template engine that can run a basic subset of Blade
class View {
public static function make(string $path, array $data = []): View
{
$view = new View();
$view->path = $path;
$view->data = $data;
return $view;
}
public function with(string $key, $value): View
{
$this->data[$key] = $value;
return $this;
}
protected static function class(array $classes): string
{
$activeClasses = [];
foreach ($classes as $class => $active) {
if ($active) $activeClasses[] = $class;
}
if (count($activeClasses) > 0) {
return 'class="' . implode(', ', $activeClasses) . '"';
}
return '';
}
public function render(): string
{
extract($this->data);
ob_start();
eval('?>' . preg_replace(
[
'/{{--.*--}}/Us',
'/@php/',
'/@endphp/',
'/@if\s*\((.*)\)/',
'/@elseif\s*\((.*)\)/',
'/@else/',
'/@endif/',
'/@while\s*\((.*)\)/',
'/@endwhile/',
'/@foreach\s*\((.*)\)/',
'/@endforeach/',
'/@for\s*\((.*)\)/',
'/@endfor/',
'/@continue/',
'/@break/',
'/@include\((.*)\)/',
'/@json\((.*)\)/',
'/@class\((.*)\)/',
'/([^@]){{(.*)}}/Us',
'/\@{{(.*)}}/Us',
'/{!!(.*)!!}/Us'
],
[
'',
'<?php',
'?>',
'<?php if ($1): ?>',
'<?php elseif ($1): ?>',
'<?php else: ?>',
'<?php endif ?>',
'<?php while ($1): ?>',
'<?php endwhile ?>',
'<?php foreach ($1): ?>',
'<?php endforeach ?>',
'<?php for ($1): ?>',
'<?php endfor ?>',
'<?php continue ?>',
'<?php break ?>',
'<?= view($1)->render() ?>',
'<?= json_encode($1) ?>',
'<?= View::class($1) ?>',
'$1<?= htmlspecialchars($2, ENT_QUOTES, \'UTF-8\') ?>',
'{{ $1 }}',
'<?= $1 ?>'
],
file_get_contents(ROOT . '/views/' . str_replace('.', '/', $this->path) . '.blade.php')
));
$html = ob_get_contents();
ob_end_clean();
if (!config('app.debug')) {
$html = preg_replace(['/\>[^\S ]+/s', '/[^\S ]+\</s', '/(\s)+/s'], ['>', '<', '$1'], $html);
}
return $html;
}
}
function view(string $path, array $data = []): View {
return View::make($path, $data);
}
// ##########################################################
// ####################### MODEL ############################
// ##########################################################
// Model and query builder class
abstract class Model implements JsonSerializable
{
protected static ?string $table = null;
protected static string $primaryKey = 'id';
protected static array $attributes = [];
protected static array $hidden = [];
public static function table(): string
{
if (is_null(static::$table)) {
static::$table = strtolower(static::class) . 's';
}
return static::$table;
}
public static function primaryKey(): string
{
return static::$primaryKey;
}
public static function attributes(): array
{
return static::$attributes;
}
public static function hidden(): array
{
if (!in_array('deleted_at', static::$hidden)) static::$hidden[] = 'deleted_at';
if (!in_array('_database', static::$hidden)) static::$hidden[] = '_database';
return static::$hidden;
}
public function __construct()
{
foreach (static::attributes() as $column => $value) {
if (!isset($this->{$column})) {
$this->{$column} = $value;
}
}
}
public function save()
{
$values = get_object_vars($this);
if (!isset($values[static::primaryKey()])) {
$values['created_at'] = date('Y-m-d H:i:s');
$values['updated_at'] = date('Y-m-d H:i:s');
$columns = [];
foreach ($values as $column => $value) {
$columns[] = '`' . $column . '`';
}
DB::query('INSERT INTO `' . static::table() . '` (' . implode(', ', $columns) . ') ' .
'VALUES (' . implode(', ', array_fill(0, count($values), '?')) . ')', array_values($values));
$model = DB::query('SELECT * FROM `' . static::table() .
'` WHERE `' . static::primaryKey() . '` = ?', [DB::lastInsertId()])->fetch();
foreach ($model as $column => $value) {
if (!isset($this->{$column}) || $this->{$column} != $value) {
$this->{$column} = $value;
}
}
$this->_database = get_object_vars($this);
} else {
$updates = [];
foreach ($values as $column => $value) {
if ($column != '_database' && $value != $this->_database[$column]) {
$updates[$column] = $value;
}
}
if (count($updates) > 0) {
$updates['updated_at'] = date('Y-m-d H:i:s');
$sets = [];
foreach ($updates as $column => $value) {
$sets[] = '`' . $column . '` = ?';
}
DB::query('UPDATE `' . static::table() . '` SET ' . implode(', ', $sets) .
' WHERE `' . static::primaryKey() . '` = ?', [...array_values($updates), $this->id]);
foreach ($updates as $column => $value) {
$this->_database[$column] = $value;
}
}
}
}
public function delete()
{
if (isset($this->{static::primaryKey()})) {
$this->deleted_at = date('Y-m-d H:i:s');
$this->save();
}
}
public function jsonSerialize()
{
if (method_exists($this, 'toJson')) {
return $this->toJson();
}
$values = get_object_vars($this);
foreach (static::hidden() as $column) {
unset($values[$column]);
}
return $values;
}
public static function all(): array
{
return (new QueryBuilder(static::class))->get();
}
public static function withTrashed(): QueryBuilder
{
return (new QueryBuilder(static::class))->withTrashed();
}
public static function onlyTrashed(): QueryBuilder
{
return (new QueryBuilder(static::class))->onlyTrashed();
}
public static function where(string $column, $operator, $value = null): QueryBuilder
{
return (new QueryBuilder(static::class))->where($column, $operator, $value);
}
public static function orderBy(string $column): QueryBuilder
{
return (new QueryBuilder(static::class))->orderBy($column);
}
public static function orderByDesc(string $column): QueryBuilder
{
return (new QueryBuilder(static::class))->orderByDesc($column);
}
public static function limit(int $limit): QueryBuilder
{
return (new QueryBuilder(static::class))->limit($limit);
}
}
class QueryBuilder
{
protected string $model;
protected array $whereOperators = ['deleted_at' => 'IS NULL'];
protected array $whereValues = [];
protected array $orderBys = [];
protected ?int $limit = null;
public function __construct($model)
{
$this->model = $model;
}
public function withTrashed(): QueryBuilder
{
unset($this->whereOperators['deleted_at']);
return $this;
}
public function onlyTrashed(): QueryBuilder
{
$this->whereOperators['deleted_at'] = 'IS NOT NULL';
return $this;
}
public function where(string $column, $operator, $value = null): QueryBuilder
{
if ($value == null) {
$value = $operator;
$operator = '=';
}
$this->whereOperators[$column] = $operator;
if ($value != '') {
$this->whereValues[$column] = $value;
}
return $this;
}
public function orderBy(string $column): QueryBuilder
{
$this->orderBys[$column] = 'ASC';
return $this;
}
public function orderByDesc(string $column): QueryBuilder
{
$this->orderBys[$column] = 'DESC';
return $this;
}
public function limit(int $limit): QueryBuilder
{
$this->limit = $limit;
return $this;
}
public function query(): string
{
$wheres = [];
foreach ($this->whereOperators as $column => $operator) {
$wheres[] = '`' . $column . '` ' . $operator . (isset($this->whereValues[$column]) ? ' ?' : '');
}
$orderBys = [];
foreach ($this->orderBys as $column => $order) {
$orderBys[] = '`' . $column . '` ' . $order;
}
return 'SELECT * FROM `' . ($this->model . '::table')() . '`' .
(count($wheres) > 0 ? ' WHERE ' . implode(' AND ', $wheres) : '') .
(count($orderBys) > 0 ? ' ORDER BY ' . implode(', ', $orderBys) : '') .
($this->limit != null ? ' LIMIT ' . $this->limit : '');
}
public function get(): array
{
$query = DB::query($this->query(), array_values($this->whereValues));
$query->setFetchMode(PDO::FETCH_CLASS, $this->model);
$models = $query->fetchAll();
for ($i = 0; $i < count($models); $i++) {
$models[$i]->_database = get_object_vars($models[$i]);
}
return $models;
}
public function first(): ?object
{
$this->limit(1);
$query = DB::query($this->query(), array_values($this->whereValues));
$query->setFetchMode(PDO::FETCH_CLASS, $this->model);
$model = $query->fetch();
if ($model != false) {
$model->_database = get_object_vars($model);
return $model;
}
return null;
}
}
// ##########################################################
// ######################### ROUTER #########################
// ##########################################################
class Route {
protected static array $routes = [];
public static function match(array $methods, string $route, callable $callback): Route {
$routeInstance = new Route();
$routeInstance->methods = $methods;
$routeInstance->route = $route;
$routeInstance->callback = $callback;
$routeInstance->name = null;
static::$routes[] = $routeInstance;
return $routeInstance;
}
public static function get(string $route, callable $callback): Route {
return static::match([ 'get' ], $route, $callback);
}
public static function post(string $route, callable $callback): Route {
return static::match([ 'post' ], $route, $callback);
}
public static function any(string $route, callable $callback): Route {
return static::match([ 'get', 'post' ], $route, $callback);
}
public static function view(string $route, string $view, array $data = []): Route {
return static::get($route, function () use ($view, $data) {
return view($view, $data);
});
}
public function name(string $name): Route {
$this->name = $name;
return $this;
}
protected static function handleResponse($response): void {
if ($response instanceof View) {
echo $response->render();
exit;
}
if (is_string($response)) {
echo $response;
exit;
}
if (is_array($response) || is_object($response)) {
header('Content-Type: application/json');
echo json_encode($response);
exit;
}
}
public static function run(): void {
$path = rtrim(preg_replace('#/+#', '/', strtok($_SERVER['REQUEST_URI'], '?')), '/');
if ($path == '') $path = '/';
$method = strtolower($_SERVER['REQUEST_METHOD']);
foreach (static::$routes as $route) {
if (
in_array($method, $route->methods) &&
preg_match('#^' . preg_replace('/{.*}/U', '([^/]*)', $route->route) . '$#', $path, $values)
) {
array_shift($values);
// Route model binding
preg_match('/{(.*)}/U', $route->route, $names);
array_shift($names);
foreach ($names as $index => $name) {
if (class_exists($name)) {
$model = ($name . '::where')(($name . '::primaryKey')(), $values[$index])->first();
if ($model != null) {
$values[$index] = $model;
} else {
abort(404);
}
}
}
static::handleResponse(($route->callback)(...$values));
}
}
// Return 404 error page
abort(404);
}
public static function getRoute(string $name, ...$parameters): ?string {
foreach (static::$routes as $route) {
if ($route->name == $name) {
preg_match('/{(.*)}/U', $route->route, $names);
array_shift($names);
$values = [];
for ($i = 0; $i < count($parameters); $i++) {
$values[] = $parameters[$i]->{($names[$i] . '::primaryKey')()};
}
$patterns = [];
foreach ($names as $name) {
$patterns[] = '{' . $name . '}';
}
return str_replace($patterns, $values, $route->route);
}
}
return null;
}
}
function route(string $name, ...$parameters): ?string {
return Route::getRoute($name, ...$parameters);
}
// ##########################################################
// ####################### UTILS ############################
// ##########################################################
// Config
$_config = require_once ROOT . '/config.php';
function config(string $key) {
global $_config;
$value = $_config;
$parts = explode('.', $key);
foreach ($parts as $part) {
$value = $value[$part];
}
return $value;
}
// Die and dump
function dd($data): void {
header('Content-Type: application/json');
echo json_encode($data);
exit;
}
// Abort
function abort(int $errorCode): void {
http_response_code($errorCode);
echo view('errors.' . $errorCode)->render();
exit;
}
// ##########################################################
// #################### AUTOLOADER ##########################
// ##########################################################
$_autoloadFolders = [ ROOT . '/controllers', ROOT . '/core', ROOT . '/models' ];
function _searchAutoloadFolders(string $folder) {
global $_autoloadFolders;
$files = glob($folder . '/*');
foreach ($files as $file) {
if (is_dir($file)) {
$_autoloadFolders[] = $file;
_searchAutoloadFolders($file);
}
}
}
foreach ($_autoloadFolders as $folder) {
_searchAutoloadFolders($folder);
}
spl_autoload_register(function (string $class) use ($_autoloadFolders): void {
foreach ($_autoloadFolders as $folder) {
$path = $folder . '/' . $class . '.php';
if (file_exists($path)) {
require_once $path;
return;
}
}
});
// ##########################################################
// ####################### APP ##############################
// ##########################################################
// Connect to MySQL database
if (config('database.connection') == 'mysql') {
DB::connect(
'mysql:host=' . config('database.host') . ';port=' . config('database.port') . ';' .
'dbname=' . config('database.database') . ';charset=utf8mb4',
config('database.user'), config('database.password')
);
}
// Load routes
require_once ROOT . '/routes.php';
// Run router
Route::run();
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment