Created
December 30, 2021 17:27
-
-
Save bplaat/314c577cc378a7c4e50e02ae48b1c105 to your computer and use it in GitHub Desktop.
Yet another single file PHP MVC framework
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| RewriteEngine On | |
| RewriteCond %{REQUEST_FILENAME} !-f | |
| RewriteRule ^.*$ index.php [L] |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <?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