├── LICENSE ├── README.md ├── composer.json ├── doc └── mvc.png └── src ├── App.php ├── Controller ├── AttributeInterface.php ├── AttributeTrait.php ├── BasicAuth.php ├── ControllerInterface.php ├── Error404.php ├── Error405.php └── RouteTrait.php ├── Emitter ├── EmitterInterface.php └── SapiEmitter.php ├── Exception ├── ControllerException.php ├── ExceptionInterface.php ├── InvalidConfigException.php └── ResponseException.php └── Response └── HaltResponse.php /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2022 Enrico Zimuel (https://www.zimuel.it) 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in 11 | all copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | THE SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## SimpleMVC 2 | 3 | [![Build status](https://github.com/simplemvc/framework/workflows/PHP%20test/badge.svg)](https://github.com/simplemvc/framework/actions) 4 | 5 | **SimpleMVC** is an [MVC](https://en.wikipedia.org/wiki/Model%E2%80%93view%E2%80%93controller) framework 6 | for PHP based on the [KISS](https://en.wikipedia.org/wiki/KISS_principle) principle: 7 | 8 | > "Keep It Simple, Stupid" 9 | 10 | The goal of this project is to offer a simple to use and fast framework for PHP applications 11 | using [PSR](https://www.php-fig.org/psr/) standards. 12 | 13 | SimpleMVC uses the [dependency injection](https://en.wikipedia.org/wiki/Dependency_injection) pattern to manage 14 | the dependencies between classes and the [FastRoute](https://github.com/nikic/FastRoute) library 15 | for implementing the routing system. 16 | 17 | SimpleMVC uses the following PSR standards, from the [PHP-FIG](https://www.php-fig.org/) initiative: 18 | 19 | - [PSR-11](https://www.php-fig.org/psr/psr-11/) for DI Container; 20 | - [PSR-7](https://www.php-fig.org/psr/psr-7/) for HTTP message; 21 | - [PSR-3](https://www.php-fig.org/psr/psr-3/) for logging; 22 | 23 | This project was born as educational library for the course **PHP Programming** by [Enrico Zimuel](https://www.zimuel.it/) 24 | at [ITS ICT Piemonte](http://www.its-ictpiemonte.it/) in Italy. 25 | 26 | Since than, the project has been evoluted and used also for building web application 27 | in production. We decided to create a more general purpose project and this was the beginning 28 | of this repository. 29 | 30 | ## Introduction 31 | 32 | SimpleMVC implements the [Model–View–Controller](https://en.wikipedia.org/wiki/Model%E2%80%93view%E2%80%93controller) (MVC) 33 | architectural pattern using [Dependency injection](https://en.wikipedia.org/wiki/Dependency_injection) 34 | and [PSR](https://www.php-fig.org/psr/) standards. 35 | 36 | A SimpleMVC application looks as follows: 37 | 38 | ```php 39 | chdir(dirname(__DIR__)); 40 | require 'vendor/autoload.php'; 41 | 42 | use DI\ContainerBuilder; 43 | use SimpleMVC\App; 44 | use SimpleMVC\Emitter\SapiEmitter; 45 | 46 | $builder = new ContainerBuilder(); 47 | $builder->addDefinitions('config/container.php'); 48 | $container = $builder->build(); 49 | 50 | $app = new App($container); 51 | $app->bootstrap(); // optional 52 | $response = $app->dispatch(); // PSR-7 response 53 | 54 | SapiEmitter::emit($response); 55 | ``` 56 | 57 | In this example we use a DI container with [PHP-DI](https://php-di.org/). We create a `SimpleMVC\App` object 58 | using the previous container. 59 | 60 | The application can be configured using the `config/container.php` file. An example is as follows: 61 | 62 | ```php 63 | // config/container.php 64 | use App\Controller; 65 | 66 | return [ 67 | 'config' => [ 68 | 'routing' => [ 69 | 'routes' => [ 70 | [ 'GET', '/', Controller\HomePage::class ] 71 | ] 72 | ] 73 | ] 74 | ]; 75 | ``` 76 | 77 | The steps to manage the request are: 78 | 79 | - `bootstrap()` (optional), here you can specify any bootstrap requirements; 80 | - `dispatch()`, where the HTTP request is dispatched, executing the controller specified in the route. 81 | 82 | The `dispatch()` returns a PSR-7 response. Finally, we can render the response using an emitter. 83 | In this example we used a `SapiEmitter` to render the PSR-7 response in the standard output. 84 | 85 | The previous PHP script is basically a front controller of an MVC application (see diagram below). 86 | 87 | ![MVC diagram](doc/mvc.png) 88 | 89 | In this diagram the front controller is stored in a `public/index.php` file. 90 | The `public` folder is usually the document root of a web server. 91 | 92 | ## Using a pipeline of controllers 93 | 94 | If you want you can specify a pipeline of controllers to be executed for a specific route. 95 | For instance, imagine to have a route as follows: 96 | 97 | ```php 98 | // config/container.php 99 | use App\Controller; 100 | use SimpleMVC\Controller\BasicAuth; 101 | 102 | return [ 103 | 'config' => [ 104 | 'routing' => [ 105 | 'routes' => [ 106 | [ 'GET', '/admin', [BasicAuth::class, Controller\HomePage::class ] 107 | ] 108 | ], 109 | 'authentication' => [ 110 | 'username' => 'admin', 111 | 'password' => '1234567890' 112 | ] 113 | ] 114 | ]; 115 | ``` 116 | 117 | The route `GET /admin` will execute the `BasicAuth` controller first and, if the 118 | authentication will be successfull, the `HomePage` controller after. 119 | 120 | This is a pipeline of two controllers executed in order. The `BasicAuth` is a 121 | simple implementation of the [Basic Access Authentication](https://en.wikipedia.org/wiki/Basic_access_authentication). This controller uses the `username` and `password` 122 | configuration in the `authentication` section. 123 | 124 | If the authentication is not success, the `BasicAuth` emits an `HaltResponse` that 125 | will stop the pipeline execution. `HaltResponse` is a special PSR-7 that informs 126 | the SimpleMVC framework to halt the execution. 127 | 128 | ## Passing attributes between controllers 129 | 130 | If you need to pass an attribute (parameter) from a controller to another in a 131 | pipeline of execution you can use the `AttributeInterface`. For instance, imagine 132 | to pass a `foo` attribute from a controller `A` to controller `B`, using the follwing 133 | routing pipeline: 134 | 135 | ```php 136 | // config/container.php 137 | use App\Controller; 138 | 139 | return [ 140 | 'config' => [ 141 | 'routing' => [ 142 | 'routes' => [ 143 | [ 'GET', '/', [Controller\A::class, Controller\B::class ] 144 | ] 145 | ] 146 | ] 147 | ]; 148 | ``` 149 | 150 | You need to create the controller A as follows: 151 | 152 | ```php 153 | use Psr\Http\Message\ResponseInterface; 154 | use Psr\Http\Message\ServerRequestInterface; 155 | use SimpleMVC\Controller\AttributeInterface; 156 | use SimpleMVC\Controller\AttributeTrait; 157 | use SimpleMVC\Controller\ControllerInterface; 158 | 159 | class A implements ControllerInterface, AttributeInterface 160 | { 161 | use AttributeTrait; 162 | 163 | public function execute( 164 | ServerRequestInterface $request, 165 | ResponseInterface $response 166 | ): ResponseInterface 167 | { 168 | $this->addRequestAttribute('foo', 'bar'); 169 | return $response; 170 | } 171 | } 172 | ``` 173 | 174 | We can use an `AttributeTrait` that implements the `AttributeInterface` with 175 | the `addRequestAttribute(string $name, $value)`. This function adds a [PSR-7](https://www.php-fig.org/psr/psr-7/) 176 | attribute into the `$request` for the next controller. 177 | 178 | In order to get the `foo` parameter in the `B` controller you can use the 179 | PSR-7 standard function `getAttribute()` from the HTTP request, as follows: 180 | 181 | ```php 182 | use Psr\Http\Message\ResponseInterface; 183 | use Psr\Http\Message\ServerRequestInterface; 184 | use SimpleMVC\Controller\ControllerInterface; 185 | 186 | class B implements ControllerInterface 187 | { 188 | public function execute( 189 | ServerRequestInterface $request, 190 | ResponseInterface $response 191 | ): ResponseInterface 192 | { 193 | $attribute = $request->getAttribute('foo'); 194 | pritnf("Attribute is: %s", $attribute); 195 | return $response; 196 | } 197 | } 198 | ``` 199 | 200 | Notice that you don't need to implement the `AttributeInterface` for the `B` controller 201 | since we only need to read from the `$request`. 202 | 203 | 204 | ## Quickstart 205 | 206 | You can start using the framework with the [skeleton](https://github.com/simplemvc/skeleton) application. 207 | 208 | ## Copyright 209 | 210 | The author of this software is [Enrico Zimuel](https://github.com/ezimuel/) and other [contributors](https://github.com/simplemvc/framework/graphs/contributors). 211 | 212 | This software is released under the [MIT License](/LICENSE). 213 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "simplemvc/framework", 3 | "type": "library", 4 | "description": "SimpleMVC framework", 5 | "keywords": [ 6 | "framework", 7 | "simple", 8 | "mvc", 9 | "psr-7" 10 | ], 11 | "license": "MIT", 12 | "require": { 13 | "php": "^7.4 || ^8.0", 14 | "nikic/fast-route": "^1.3", 15 | "nyholm/psr7": "^1.5", 16 | "nyholm/psr7-server": "^1.0", 17 | "psr/log": "^1|^2|^3", 18 | "psr/http-client": "^1.0", 19 | "psr/container": "^1.0 || ^2.0" 20 | }, 21 | "require-dev": { 22 | "phpstan/phpstan": "^1.8", 23 | "phpunit/phpunit": "^9.5", 24 | "mockery/mockery": "^1.5", 25 | "phpstan/phpstan-mockery": "^1.1" 26 | }, 27 | "autoload": { 28 | "psr-4": { 29 | "SimpleMVC\\": "src/" 30 | } 31 | }, 32 | "autoload-dev": { 33 | "psr-4": { 34 | "SimpleMVC\\Test\\": "tests/" 35 | } 36 | }, 37 | "scripts": { 38 | "test": "vendor/bin/phpunit --colors=always", 39 | "code-coverage": "vendor/bin/phpunit --colors=always --coverage-clover clover.xml", 40 | "phpstan": "vendor/bin/phpstan analyse -c phpstan.neon" 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /doc/mvc.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/simplemvc/framework/c5cc61bfc1a870cd8f61194b9946a92f27ff6d17/doc/mvc.png -------------------------------------------------------------------------------- /src/App.php: -------------------------------------------------------------------------------- 1 | startTime = microtime(true); 51 | $this->container = $container; 52 | 53 | try { 54 | $this->config = $container->get('config'); 55 | } catch (NotFoundExceptionInterface $e) { 56 | throw new InvalidConfigException( 57 | 'The configuration is missing! Be sure to have a "config" key in the container' 58 | ); 59 | } 60 | if (!isset($this->config['routing']['routes'])) { 61 | throw new InvalidConfigException( 62 | 'The ["routing"]["routes"] is missing in configuration' 63 | ); 64 | } 65 | $routes = $this->config['routing']['routes']; 66 | // Routing initialization 67 | $this->dispatcher = cachedDispatcher(function(RouteCollector $r) use ($routes) { 68 | foreach ($routes as $route) { 69 | $r->addRoute($route[0], $route[1], $route[2]); 70 | } 71 | }, [ 72 | 'cacheFile' => $this->config['routing']['cache'] ?? '', 73 | 'cacheDisabled' => !isset($this->config['routing']['cache']) 74 | ]); 75 | 76 | // Logger initialization 77 | try { 78 | $this->logger = $container->get(LoggerInterface::class); 79 | } catch (NotFoundExceptionInterface $e) { 80 | $this->logger = new NullLogger(); 81 | } 82 | 83 | if (isset($this->config['bootstrap']) && !is_callable($this->config['bootstrap'])) { 84 | throw new InvalidConfigException('The ["bootstrap"] must a callable'); 85 | } 86 | } 87 | 88 | public function getContainer(): ContainerInterface 89 | { 90 | return $this->container; 91 | } 92 | 93 | public function getDispatcher(): Dispatcher 94 | { 95 | return $this->dispatcher; 96 | } 97 | 98 | /** 99 | * @return mixed[] 100 | */ 101 | public function getConfig(): array 102 | { 103 | return $this->config; 104 | } 105 | 106 | public function getLogger(): LoggerInterface 107 | { 108 | return $this->logger; 109 | } 110 | 111 | public function bootstrap(): void 112 | { 113 | if (isset($this->config['bootstrap'])) { 114 | $start = microtime(true); 115 | $this->config['bootstrap']($this->container); 116 | $this->logger->debug(sprintf("Bootstrap execution: %.3f sec", microtime(true) - $start)); 117 | } 118 | } 119 | 120 | /** 121 | * @throws ControllerException 122 | */ 123 | public function dispatch(ServerRequestInterface $request): ResponseInterface 124 | { 125 | $this->logger->info(sprintf( 126 | "Request: %s %s", 127 | $request->getMethod(), 128 | $request->getUri()->getPath() 129 | )); 130 | 131 | $routeInfo = $this->dispatcher->dispatch( 132 | $request->getMethod(), 133 | $request->getUri()->getPath() 134 | ); 135 | $controllerName = null; 136 | switch ($routeInfo[0]) { 137 | case Dispatcher::NOT_FOUND: 138 | $this->logger->warning('Controller not found (404)'); 139 | $controllerName = $this->config['errors']['404'] ?? Error404::class; 140 | break; 141 | case Dispatcher::METHOD_NOT_ALLOWED: 142 | $this->logger->warning('Method not allowed (405)'); 143 | $controllerName = $this->config['errors']['405'] ?? Error405::class; 144 | break; 145 | case Dispatcher::FOUND: 146 | $controllerName = $routeInfo[1]; 147 | if (isset($routeInfo[2])) { 148 | foreach ($routeInfo[2] as $name => $value) { 149 | $request = $request->withAttribute($name, $value); 150 | } 151 | } 152 | break; 153 | } 154 | // default HTTP response 155 | $response = new Response(200); 156 | 157 | if (!is_array($controllerName)) { 158 | $controllerName = [$controllerName]; 159 | } 160 | foreach ($controllerName as $name) { 161 | $this->logger->debug(sprintf("Executing %s", $name)); 162 | try { 163 | $controller = $this->container->get($name); 164 | $response = $controller->execute($request, $response); 165 | if ($response instanceof HaltResponse) { 166 | $this->logger->debug(sprintf("Found HaltResponse in %s", $name)); 167 | break; 168 | } 169 | // Add the PSR-7 attributes to the next request 170 | if ($controller instanceof AttributeInterface) { 171 | foreach ($controller->getRequestAttribute() as $key => $value) { 172 | $request = $request->withAttribute($key, $value); 173 | } 174 | } 175 | } catch (NotFoundExceptionInterface $e) { 176 | throw new ControllerException(sprintf( 177 | 'The controller name %s cannot be retrieved from the container', 178 | $name 179 | )); 180 | } 181 | } 182 | 183 | $this->logger->info(sprintf( 184 | "Response: %d", 185 | $response->getStatusCode() 186 | )); 187 | 188 | $this->logger->info(sprintf("Execution time: %.3f sec", microtime(true) - $this->startTime)); 189 | $this->logger->info(sprintf("Memory usage: %d bytes", memory_get_usage(true))); 190 | 191 | return $response; 192 | } 193 | 194 | /** 195 | * Returns a PSR-7 request from globals ($_GET, $_POST, $_SERVER, etc) 196 | */ 197 | public static function buildRequestFromGlobals(): ServerRequestInterface 198 | { 199 | $factory = new Psr17Factory(); 200 | return (new ServerRequestCreator($factory, $factory, $factory, $factory)) 201 | ->fromGlobals(); 202 | } 203 | } -------------------------------------------------------------------------------- /src/Controller/AttributeInterface.php: -------------------------------------------------------------------------------- 1 | attributes[$name] = $value; 24 | } 25 | 26 | /** 27 | * @return mixed 28 | */ 29 | public function getRequestAttribute(string $name = null) 30 | { 31 | if (empty($name)) { 32 | return $this->attributes; 33 | } 34 | return $this->attributes[$name] ?? null; 35 | } 36 | } -------------------------------------------------------------------------------- /src/Controller/BasicAuth.php: -------------------------------------------------------------------------------- 1 | get('config'); 40 | if (!isset($config['authentication'])) { 41 | throw new InvalidConfigException( 42 | 'The ["config"]["authentication"] is missing in configuration' 43 | ); 44 | } 45 | $this->username = $config['authentication']['username'] ?? null; 46 | $this->password = $config['authentication']['password'] ?? null; 47 | $this->realm = $config['authentication']['realm'] ?? ''; 48 | 49 | if (is_null($this->username) || is_null($this->password)) { 50 | throw new InvalidConfigException( 51 | 'Username and password are missing in ["config"]["authentication"]' 52 | ); 53 | } 54 | } 55 | 56 | /** 57 | * Implements Basic Access Authentication 58 | * 59 | * @see https://en.wikipedia.org/wiki/Basic_access_authentication 60 | */ 61 | public function execute(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface 62 | { 63 | $auth = $request->getHeader('Authorization'); 64 | if (empty($auth)) { 65 | return new HaltResponse(401, ['WWW-Authenticate' => "Basic realm=\"{$this->realm}\""]); 66 | } 67 | list (, $credential) = explode('Basic ', $auth[0]); 68 | list($username, $password) = explode(':', base64_decode($credential)); 69 | if ($username !== $this->username || $password !== $this->password) { 70 | return new HaltResponse(401, ['WWW-Authenticate' => "Basic realm=\"{$this->realm}\""]); 71 | } 72 | return $response; 73 | } 74 | } -------------------------------------------------------------------------------- /src/Controller/ControllerInterface.php: -------------------------------------------------------------------------------- 1 | getMethod(); 22 | if (method_exists($this, $method)) { 23 | return $this->$method($request, $response); 24 | } 25 | return new Response( 26 | 405, 27 | [], 28 | 'Method Not Allowed' 29 | ); 30 | } 31 | } -------------------------------------------------------------------------------- /src/Emitter/EmitterInterface.php: -------------------------------------------------------------------------------- 1 | getStatusCode(); 22 | $reasonPhrase = $response->getReasonPhrase(); 23 | header(sprintf( 24 | 'HTTP/%s %d%s', 25 | $response->getProtocolVersion(), 26 | $statusCode, 27 | empty($reasonPhrase) ? '' : ' ' . $reasonPhrase 28 | ), true, $statusCode); 29 | 30 | // headers 31 | $headers = $response->getHeaders(); 32 | foreach ($headers as $name => $values) { 33 | foreach ($values as $value) { 34 | header(sprintf('%s: %s', $name, $value), false); 35 | } 36 | } 37 | 38 | // Set the SimpleMVC Server header 39 | header(sprintf("Server: SimpleMVC %s/PHP %s", App::VERSION, phpversion())); 40 | 41 | // body 42 | $body = (string) $response->getBody(); 43 | if (!empty($body)) { 44 | header(sprintf("Content-Length: %d", strlen($body))); 45 | } 46 | echo $body; 47 | } 48 | } -------------------------------------------------------------------------------- /src/Exception/ControllerException.php: -------------------------------------------------------------------------------- 1 |