├── .gitignore ├── Application.php ├── Controller.php ├── Model.php ├── Request.php ├── Response.php ├── Router.php ├── Session.php ├── UserModel.php ├── View.php ├── composer.json ├── db ├── Database.php └── DbModel.php ├── exception ├── ForbiddenException.php └── NotFoundException.php ├── form ├── BaseField.php ├── Field.php ├── Form.php └── TextareaField.php └── middlewares ├── AuthMiddleware.php └── BaseMiddleware.php /.gitignore: -------------------------------------------------------------------------------- 1 | /vendor/ 2 | -------------------------------------------------------------------------------- /Application.php: -------------------------------------------------------------------------------- 1 | 16 | * @package app 17 | */ 18 | class Application 19 | { 20 | const EVENT_BEFORE_REQUEST = 'beforeRequest'; 21 | const EVENT_AFTER_REQUEST = 'afterRequest'; 22 | 23 | protected array $eventListeners = []; 24 | 25 | public static Application $app; 26 | public static string $ROOT_DIR; 27 | public string $userClass; 28 | public string $layout = 'main'; 29 | public Router $router; 30 | public Request $request; 31 | public Response $response; 32 | public ?Controller $controller = null; 33 | public Database $db; 34 | public Session $session; 35 | public View $view; 36 | public ?UserModel $user; 37 | 38 | public function __construct($rootDir, $config) 39 | { 40 | $this->user = null; 41 | $this->userClass = $config['userClass']; 42 | self::$ROOT_DIR = $rootDir; 43 | self::$app = $this; 44 | $this->request = new Request(); 45 | $this->response = new Response(); 46 | $this->router = new Router($this->request, $this->response); 47 | $this->db = new Database($config['db']); 48 | $this->session = new Session(); 49 | $this->view = new View(); 50 | 51 | $userId = Application::$app->session->get('user'); 52 | if ($userId) { 53 | $key = $this->userClass::primaryKey(); 54 | $this->user = $this->userClass::findOne([$key => $userId]); 55 | } 56 | } 57 | 58 | public static function isGuest() 59 | { 60 | return !self::$app->user; 61 | } 62 | 63 | public function login(UserModel $user) 64 | { 65 | $this->user = $user; 66 | $className = get_class($user); 67 | $primaryKey = $className::primaryKey(); 68 | $value = $user->{$primaryKey}; 69 | Application::$app->session->set('user', $value); 70 | 71 | return true; 72 | } 73 | 74 | public function logout() 75 | { 76 | $this->user = null; 77 | self::$app->session->remove('user'); 78 | } 79 | 80 | public function run() 81 | { 82 | $this->triggerEvent(self::EVENT_BEFORE_REQUEST); 83 | try { 84 | echo $this->router->resolve(); 85 | } catch (\Exception $e) { 86 | echo $this->router->renderView('_error', [ 87 | 'exception' => $e, 88 | ]); 89 | } 90 | } 91 | 92 | public function triggerEvent($eventName) 93 | { 94 | $callbacks = $this->eventListeners[$eventName] ?? []; 95 | foreach ($callbacks as $callback) { 96 | call_user_func($callback); 97 | } 98 | } 99 | 100 | public function on($eventName, $callback) 101 | { 102 | $this->eventListeners[$eventName][] = $callback; 103 | } 104 | } -------------------------------------------------------------------------------- /Controller.php: -------------------------------------------------------------------------------- 1 | 15 | * @package thecodeholic\phpmvc 16 | */ 17 | class Controller 18 | { 19 | public string $layout = 'main'; 20 | public string $action = ''; 21 | 22 | /** 23 | * @var \thecodeholic\phpmvc\BaseMiddleware[] 24 | */ 25 | protected array $middlewares = []; 26 | 27 | public function setLayout($layout): void 28 | { 29 | $this->layout = $layout; 30 | } 31 | 32 | public function render($view, $params = []): string 33 | { 34 | return Application::$app->router->renderView($view, $params); 35 | } 36 | 37 | public function registerMiddleware(BaseMiddleware $middleware) 38 | { 39 | $this->middlewares[] = $middleware; 40 | } 41 | 42 | /** 43 | * @return \thecodeholic\phpmvc\middlewares\BaseMiddleware[] 44 | */ 45 | public function getMiddlewares(): array 46 | { 47 | return $this->middlewares; 48 | } 49 | } -------------------------------------------------------------------------------- /Model.php: -------------------------------------------------------------------------------- 1 | 15 | * @package thecodeholic\phpmvc 16 | */ 17 | class Model 18 | { 19 | const RULE_REQUIRED = 'required'; 20 | const RULE_EMAIL = 'email'; 21 | const RULE_MIN = 'min'; 22 | const RULE_MAX = 'max'; 23 | const RULE_MATCH = 'match'; 24 | const RULE_UNIQUE = 'unique'; 25 | 26 | public array $errors = []; 27 | 28 | public function loadData($data) 29 | { 30 | foreach ($data as $key => $value) { 31 | if (property_exists($this, $key)) { 32 | $this->{$key} = $value; 33 | } 34 | } 35 | } 36 | 37 | public function attributes() 38 | { 39 | return []; 40 | } 41 | 42 | public function labels() 43 | { 44 | return []; 45 | } 46 | 47 | public function getLabel($attribute) 48 | { 49 | return $this->labels()[$attribute] ?? $attribute; 50 | } 51 | 52 | public function rules() 53 | { 54 | return []; 55 | } 56 | 57 | public function validate() 58 | { 59 | foreach ($this->rules() as $attribute => $rules) { 60 | $value = $this->{$attribute}; 61 | foreach ($rules as $rule) { 62 | $ruleName = $rule; 63 | if (!is_string($rule)) { 64 | $ruleName = $rule[0]; 65 | } 66 | if ($ruleName === self::RULE_REQUIRED && !$value) { 67 | $this->addErrorByRule($attribute, self::RULE_REQUIRED); 68 | } 69 | if ($ruleName === self::RULE_EMAIL && !filter_var($value, FILTER_VALIDATE_EMAIL)) { 70 | $this->addErrorByRule($attribute, self::RULE_EMAIL); 71 | } 72 | if ($ruleName === self::RULE_MIN && strlen($value) < $rule['min']) { 73 | $this->addErrorByRule($attribute, self::RULE_MIN, ['min' => $rule['min']]); 74 | } 75 | if ($ruleName === self::RULE_MAX && strlen($value) > $rule['max']) { 76 | $this->addErrorByRule($attribute, self::RULE_MAX); 77 | } 78 | if ($ruleName === self::RULE_MATCH && $value !== $this->{$rule['match']}) { 79 | $this->addErrorByRule($attribute, self::RULE_MATCH, ['match' => $rule['match']]); 80 | } 81 | if ($ruleName === self::RULE_UNIQUE) { 82 | $className = $rule['class']; 83 | $uniqueAttr = $rule['attribute'] ?? $attribute; 84 | $tableName = $className::tableName(); 85 | $db = Application::$app->db; 86 | $statement = $db->prepare("SELECT * FROM $tableName WHERE $uniqueAttr = :$uniqueAttr"); 87 | $statement->bindValue(":$uniqueAttr", $value); 88 | $statement->execute(); 89 | $record = $statement->fetchObject(); 90 | if ($record) { 91 | $this->addErrorByRule($attribute, self::RULE_UNIQUE); 92 | } 93 | } 94 | } 95 | } 96 | return empty($this->errors); 97 | } 98 | 99 | public function errorMessages() 100 | { 101 | return [ 102 | self::RULE_REQUIRED => 'This field is required', 103 | self::RULE_EMAIL => 'This field must be valid email address', 104 | self::RULE_MIN => 'Min length of this field must be {min}', 105 | self::RULE_MAX => 'Max length of this field must be {max}', 106 | self::RULE_MATCH => 'This field must be the same as {match}', 107 | self::RULE_UNIQUE => 'Record with with this {field} already exists', 108 | ]; 109 | } 110 | 111 | public function errorMessage($rule) 112 | { 113 | return $this->errorMessages()[$rule]; 114 | } 115 | 116 | protected function addErrorByRule(string $attribute, string $rule, $params = []) 117 | { 118 | $params['field'] ??= $attribute; 119 | $errorMessage = $this->errorMessage($rule); 120 | foreach ($params as $key => $value) { 121 | $errorMessage = str_replace("{{$key}}", $value, $errorMessage); 122 | } 123 | $this->errors[$attribute][] = $errorMessage; 124 | } 125 | 126 | public function addError(string $attribute, string $message) 127 | { 128 | $this->errors[$attribute][] = $message; 129 | } 130 | 131 | public function hasError($attribute) 132 | { 133 | return $this->errors[$attribute] ?? false; 134 | } 135 | 136 | public function getFirstError($attribute) 137 | { 138 | $errors = $this->errors[$attribute] ?? []; 139 | return $errors[0] ?? ''; 140 | } 141 | } -------------------------------------------------------------------------------- /Request.php: -------------------------------------------------------------------------------- 1 | 14 | * @package thecodeholic\mvc 15 | */ 16 | class Request 17 | { 18 | private array $routeParams = []; 19 | 20 | public function getMethod() 21 | { 22 | return strtolower($_SERVER['REQUEST_METHOD']); 23 | } 24 | 25 | public function getUrl() 26 | { 27 | $path = $_SERVER['REQUEST_URI']; 28 | $position = strpos($path, '?'); 29 | if ($position !== false) { 30 | $path = substr($path, 0, $position); 31 | } 32 | return $path; 33 | } 34 | 35 | public function isGet() 36 | { 37 | return $this->getMethod() === 'get'; 38 | } 39 | 40 | public function isPost() 41 | { 42 | return $this->getMethod() === 'post'; 43 | } 44 | 45 | public function getBody() 46 | { 47 | $data = []; 48 | if ($this->isGet()) { 49 | foreach ($_GET as $key => $value) { 50 | $data[$key] = filter_input(INPUT_GET, $key, FILTER_SANITIZE_SPECIAL_CHARS); 51 | } 52 | } 53 | if ($this->isPost()) { 54 | foreach ($_POST as $key => $value) { 55 | $data[$key] = filter_input(INPUT_POST, $key, FILTER_SANITIZE_SPECIAL_CHARS); 56 | } 57 | } 58 | return $data; 59 | } 60 | 61 | /** 62 | * @param $params 63 | * @return self 64 | */ 65 | public function setRouteParams($params) 66 | { 67 | $this->routeParams = $params; 68 | return $this; 69 | } 70 | 71 | public function getRouteParams() 72 | { 73 | return $this->routeParams; 74 | } 75 | 76 | public function getRouteParam($param, $default = null) 77 | { 78 | return $this->routeParams[$param] ?? $default; 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /Response.php: -------------------------------------------------------------------------------- 1 | 15 | * @package thecodeholic\phpmvc 16 | */ 17 | class Response 18 | { 19 | public function statusCode(int $code) 20 | { 21 | http_response_code($code); 22 | } 23 | 24 | public function redirect($url) 25 | { 26 | header("Location: $url"); 27 | } 28 | } -------------------------------------------------------------------------------- /Router.php: -------------------------------------------------------------------------------- 1 | 16 | * @package thecodeholic\mvc 17 | */ 18 | class Router 19 | { 20 | private Request $request; 21 | private Response $response; 22 | private array $routeMap = []; 23 | 24 | public function __construct(Request $request, Response $response) 25 | { 26 | $this->request = $request; 27 | $this->response = $response; 28 | } 29 | 30 | public function get(string $url, $callback) 31 | { 32 | $this->routeMap['get'][$url] = $callback; 33 | } 34 | 35 | public function post(string $url, $callback) 36 | { 37 | $this->routeMap['post'][$url] = $callback; 38 | } 39 | 40 | /** 41 | * @return array 42 | */ 43 | public function getRouteMap($method): array 44 | { 45 | return $this->routeMap[$method] ?? []; 46 | } 47 | 48 | public function getCallback() 49 | { 50 | $method = $this->request->getMethod(); 51 | $url = $this->request->getUrl(); 52 | // Trim slashes 53 | $url = trim($url, '/'); 54 | 55 | // Get all routes for current request method 56 | $routes = $this->getRouteMap($method); 57 | 58 | $routeParams = false; 59 | 60 | // Start iterating registed routes 61 | foreach ($routes as $route => $callback) { 62 | // Trim slashes 63 | $route = trim($route, '/'); 64 | $routeNames = []; 65 | 66 | if (!$route) { 67 | continue; 68 | } 69 | 70 | // Find all route names from route and save in $routeNames 71 | if (preg_match_all('/\{(\w+)(:[^}]+)?}/', $route, $matches)) { 72 | $routeNames = $matches[1]; 73 | } 74 | 75 | // Convert route name into regex pattern 76 | $routeRegex = "@^" . preg_replace_callback('/\{\w+(:([^}]+))?}/', fn($m) => isset($m[2]) ? "({$m[2]})" : '(\w+)', $route) . "$@"; 77 | 78 | // Test and match current route against $routeRegex 79 | if (preg_match_all($routeRegex, $url, $valueMatches)) { 80 | $values = []; 81 | for ($i = 1; $i < count($valueMatches); $i++) { 82 | $values[] = $valueMatches[$i][0]; 83 | } 84 | $routeParams = array_combine($routeNames, $values); 85 | 86 | $this->request->setRouteParams($routeParams); 87 | return $callback; 88 | } 89 | } 90 | 91 | return false; 92 | } 93 | 94 | public function resolve() 95 | { 96 | $method = $this->request->getMethod(); 97 | $url = $this->request->getUrl(); 98 | $callback = $this->routeMap[$method][$url] ?? false; 99 | if (!$callback) { 100 | 101 | $callback = $this->getCallback(); 102 | 103 | if ($callback === false) { 104 | throw new NotFoundException(); 105 | } 106 | } 107 | if (is_string($callback)) { 108 | return $this->renderView($callback); 109 | } 110 | if (is_array($callback)) { 111 | /** 112 | * @var $controller \thecodeholic\phpmvc\Controller 113 | */ 114 | $controller = new $callback[0]; 115 | $controller->action = $callback[1]; 116 | Application::$app->controller = $controller; 117 | $middlewares = $controller->getMiddlewares(); 118 | foreach ($middlewares as $middleware) { 119 | $middleware->execute(); 120 | } 121 | $callback[0] = $controller; 122 | } 123 | return call_user_func($callback, $this->request, $this->response); 124 | } 125 | 126 | public function renderView($view, $params = []) 127 | { 128 | return Application::$app->view->renderView($view, $params); 129 | } 130 | 131 | public function renderViewOnly($view, $params = []) 132 | { 133 | return Application::$app->view->renderViewOnly($view, $params); 134 | } 135 | } 136 | -------------------------------------------------------------------------------- /Session.php: -------------------------------------------------------------------------------- 1 | 15 | * @package thecodeholic\phpmvc 16 | */ 17 | class Session 18 | { 19 | protected const FLASH_KEY = 'flash_messages'; 20 | 21 | public function __construct() 22 | { 23 | session_start(); 24 | $flashMessages = $_SESSION[self::FLASH_KEY] ?? []; 25 | foreach ($flashMessages as $key => &$flashMessage) { 26 | $flashMessage['remove'] = true; 27 | } 28 | $_SESSION[self::FLASH_KEY] = $flashMessages; 29 | } 30 | 31 | public function setFlash($key, $message) 32 | { 33 | $_SESSION[self::FLASH_KEY][$key] = [ 34 | 'remove' => false, 35 | 'value' => $message 36 | ]; 37 | } 38 | 39 | public function getFlash($key) 40 | { 41 | return $_SESSION[self::FLASH_KEY][$key]['value'] ?? false; 42 | } 43 | 44 | public function set($key, $value) 45 | { 46 | $_SESSION[$key] = $value; 47 | } 48 | 49 | public function get($key) 50 | { 51 | return $_SESSION[$key] ?? false; 52 | } 53 | 54 | public function remove($key) 55 | { 56 | unset($_SESSION[$key]); 57 | } 58 | 59 | public function __destruct() 60 | { 61 | $this->removeFlashMessages(); 62 | } 63 | 64 | private function removeFlashMessages() 65 | { 66 | $flashMessages = $_SESSION[self::FLASH_KEY] ?? []; 67 | foreach ($flashMessages as $key => $flashMessage) { 68 | if ($flashMessage['remove']) { 69 | unset($flashMessages[$key]); 70 | } 71 | } 72 | $_SESSION[self::FLASH_KEY] = $flashMessages; 73 | } 74 | } -------------------------------------------------------------------------------- /UserModel.php: -------------------------------------------------------------------------------- 1 | 16 | * @package thecodeholic\phpmvc 17 | */ 18 | abstract class UserModel extends DbModel 19 | { 20 | abstract public function getDisplayName(): string; 21 | } -------------------------------------------------------------------------------- /View.php: -------------------------------------------------------------------------------- 1 | 15 | * @package thecodeholic\phpmvc 16 | */ 17 | class View 18 | { 19 | public string $title = ''; 20 | 21 | public function renderView($view, array $params) 22 | { 23 | $layoutName = Application::$app->layout; 24 | if (Application::$app->controller) { 25 | $layoutName = Application::$app->controller->layout; 26 | } 27 | $viewContent = $this->renderViewOnly($view, $params); 28 | ob_start(); 29 | include_once Application::$ROOT_DIR."/views/layouts/$layoutName.php"; 30 | $layoutContent = ob_get_clean(); 31 | return str_replace('{{content}}', $viewContent, $layoutContent); 32 | } 33 | 34 | public function renderViewOnly($view, array $params) 35 | { 36 | foreach ($params as $key => $value) { 37 | $$key = $value; 38 | } 39 | ob_start(); 40 | include_once Application::$ROOT_DIR."/views/$view.php"; 41 | return ob_get_clean(); 42 | } 43 | } -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "thecodeholic/php-mvc-core", 3 | "authors": [ 4 | { 5 | "name": "Zura Sekhniashvili", 6 | "email": "zurasekhniashvili@gmail.com" 7 | } 8 | ], 9 | "require": {}, 10 | "autoload": { 11 | "psr-4": { 12 | "thecodeholic\\phpmvc\\": "." 13 | } 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /db/Database.php: -------------------------------------------------------------------------------- 1 | 17 | * @package thecodeholic\phpmvc 18 | */ 19 | class Database 20 | { 21 | public \PDO $pdo; 22 | 23 | public function __construct($dbConfig = []) 24 | { 25 | $dbDsn = $dbConfig['dsn'] ?? ''; 26 | $username = $dbConfig['user'] ?? ''; 27 | $password = $dbConfig['password'] ?? ''; 28 | 29 | $this->pdo = new \PDO($dbDsn, $username, $password); 30 | $this->pdo->setAttribute(\PDO::ATTR_ERRMODE, \PDO::ERRMODE_EXCEPTION); 31 | } 32 | 33 | public function applyMigrations() 34 | { 35 | $this->createMigrationsTable(); 36 | $appliedMigrations = $this->getAppliedMigrations(); 37 | 38 | $newMigrations = []; 39 | $files = scandir(Application::$ROOT_DIR . '/migrations'); 40 | $toApplyMigrations = array_diff($files, $appliedMigrations); 41 | foreach ($toApplyMigrations as $migration) { 42 | if ($migration === '.' || $migration === '..') { 43 | continue; 44 | } 45 | 46 | require_once Application::$ROOT_DIR . '/migrations/' . $migration; 47 | $className = pathinfo($migration, PATHINFO_FILENAME); 48 | $instance = new $className(); 49 | $this->log("Applying migration $migration"); 50 | $instance->up(); 51 | $this->log("Applied migration $migration"); 52 | $newMigrations[] = $migration; 53 | } 54 | 55 | if (!empty($newMigrations)) { 56 | $this->saveMigrations($newMigrations); 57 | } else { 58 | $this->log("There are no migrations to apply"); 59 | } 60 | } 61 | 62 | protected function createMigrationsTable() 63 | { 64 | $this->pdo->exec("CREATE TABLE IF NOT EXISTS migrations ( 65 | id INT AUTO_INCREMENT PRIMARY KEY, 66 | migration VARCHAR(255), 67 | created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP 68 | ) ENGINE=INNODB;"); 69 | } 70 | 71 | protected function getAppliedMigrations() 72 | { 73 | $statement = $this->pdo->prepare("SELECT migration FROM migrations"); 74 | $statement->execute(); 75 | 76 | return $statement->fetchAll(\PDO::FETCH_COLUMN); 77 | } 78 | 79 | protected function saveMigrations(array $newMigrations) 80 | { 81 | $str = implode(',', array_map(fn($m) => "('$m')", $newMigrations)); 82 | $statement = $this->pdo->prepare("INSERT INTO migrations (migration) VALUES 83 | $str 84 | "); 85 | $statement->execute(); 86 | } 87 | 88 | public function prepare($sql): \PDOStatement 89 | { 90 | return $this->pdo->prepare($sql); 91 | } 92 | 93 | private function log($message) 94 | { 95 | echo "[" . date("Y-m-d H:i:s") . "] - " . $message . PHP_EOL; 96 | } 97 | } -------------------------------------------------------------------------------- /db/DbModel.php: -------------------------------------------------------------------------------- 1 | 17 | * @package thecodeholic\phpmvc 18 | */ 19 | abstract class DbModel extends Model 20 | { 21 | abstract public static function tableName(): string; 22 | 23 | public static function primaryKey(): string 24 | { 25 | return 'id'; 26 | } 27 | 28 | public function save() 29 | { 30 | $tableName = $this->tableName(); 31 | $attributes = $this->attributes(); 32 | $params = array_map(fn($attr) => ":$attr", $attributes); 33 | $statement = self::prepare("INSERT INTO $tableName (" . implode(",", $attributes) . ") 34 | VALUES (" . implode(",", $params) . ")"); 35 | foreach ($attributes as $attribute) { 36 | $statement->bindValue(":$attribute", $this->{$attribute}); 37 | } 38 | $statement->execute(); 39 | return true; 40 | } 41 | 42 | public static function prepare($sql): \PDOStatement 43 | { 44 | return Application::$app->db->prepare($sql); 45 | } 46 | 47 | public static function findOne($where) 48 | { 49 | $tableName = static::tableName(); 50 | $attributes = array_keys($where); 51 | $sql = implode("AND", array_map(fn($attr) => "$attr = :$attr", $attributes)); 52 | $statement = self::prepare("SELECT * FROM $tableName WHERE $sql"); 53 | foreach ($where as $key => $item) { 54 | $statement->bindValue(":$key", $item); 55 | } 56 | $statement->execute(); 57 | return $statement->fetchObject(static::class); 58 | } 59 | } -------------------------------------------------------------------------------- /exception/ForbiddenException.php: -------------------------------------------------------------------------------- 1 | 17 | * @package thecodeholic\phpmvc\exception 18 | */ 19 | class ForbiddenException extends \Exception 20 | { 21 | protected $message = 'You don\'t have permission to access this page'; 22 | protected $code = 403; 23 | } -------------------------------------------------------------------------------- /exception/NotFoundException.php: -------------------------------------------------------------------------------- 1 | 15 | * @package thecodeholic\phpmvc\exception 16 | */ 17 | class NotFoundException extends \Exception 18 | { 19 | protected $message = 'Page not found'; 20 | protected $code = 404; 21 | } -------------------------------------------------------------------------------- /form/BaseField.php: -------------------------------------------------------------------------------- 1 | 17 | * @package thecodeholic\phpmvc\form 18 | */ 19 | abstract class BaseField 20 | { 21 | 22 | public Model $model; 23 | public string $attribute; 24 | public string $type; 25 | 26 | /** 27 | * Field constructor. 28 | * 29 | * @param \thecodeholic\phpmvc\Model $model 30 | * @param string $attribute 31 | */ 32 | public function __construct(Model $model, string $attribute) 33 | { 34 | $this->model = $model; 35 | $this->attribute = $attribute; 36 | } 37 | 38 | public function __toString() 39 | { 40 | return sprintf('