A PHP 8.2 web application created side-by-side with Lambert Mata in order to help interns become familiar with the MVC design pattern.
- Web MVC Starter
Our recommended approach, to work with the project, is to use the Windows Subsystem for Linux WSL.
You can install Docker using Docker desktop which integrates with WSL automatically without any additional set up.
To enable WSL integration flag Enable integration with my default WSL distro in Settings > Resources > WSL Integration.
The first time the mysql container is started it will create a database and credentials that can be changed in ./docker/mysql/.env. The default configuration is:
MYSQL_ROOT_PASSWORD=secret
MYSQL_USER=dev
MYSQL_PASSWORD=secret
MYSQL_DATABASE=appThe application uses Docker compose to create the infrastructure for development environment. The configuration is defined in docker-compose.yml and contains the following services:
- NGINX
- PHP-FPM
- MySQL
On each "development session" MySQL database data is persisted using a volume.
Runing the application in detached mode:
docker compose up -dChecking if the application is running correctly using:
docker psyou should see three containers running the services..
Stopping the application:
docker compose downNGINX is already configured to serve php content through PHP-FPM, though you can change the configuration file that is located in ./config/nginx.conf.
The configuration tells NGINX to route the request to index.php which is interpreted using php-fpm service that is running using port 9000.
Run the application in detached mode:
docker compose up -dDocker compose start the services defined in docker-compose.yml: php-fpm, web and db-mysql.
For each service that is defined in the configuration file, a container will be created. MySQL database is persisted using a volume which is created the first time that the docker application is started. Check the documentation for details.
To check if the application is running correctly using:
docker psyou should see three containers running the three services.
To stop the application run:
docker compose downThe docker-compose.yml is used to create a dev environment with PHP-8.2 FPM, NGINX and MySQL server.
Notice
./docker/php-fpm/Dockerfileused to create a php-fpm image indocker-compose.ymlphp-fpm service. This step is necessary to build a php-fpm image that has the required extensions.
The application entrypoint is the app.php that will bootstrap the PHP application.
The application will start reading the file routes.php, containing all the available routes with their own HTTP Verbs.
Each route consist of three parts:
-
MethodThe method that the route should respond, it can be:
POST,GET,PATCH or DELETE. -
URIThe Uniform Resource Identifier that the route should respond, it can be anything separated from
/. -
ActionThe Action is what the route must do when called, it can be tree things:
ClosurePHP Anonymous FunctionStringSimple StringArrayMust have the associationusepointing to an existingControllerandmethodseparated by@.
The Closure or Method will receive as first argument an instance of Request.
Router::get('/',['use'=>'Controller@method']);
Router::post('/post/something',function(Request $request){
/*Code goes here*/
});When the Request object is ready, then the application will execute a handle method that will search for the requested Route, if there is any. If the Route is found, then the defined Action will be executed, passing as first parameter the Request object.
Those are the handling implementations in case the Action has been declared as:
-
StringIt should get directly printed.
-
ClosureThe Closure should be executed passing as first argument the
Requestobject. -
ArrayThe specified
Methodin the specifiedControllershould be called, passing as first argument theRequestobject.
If one of the routes have been wrongly written, the application should not start.
A Route can have an infinite amount of wild cards in order to facilitate the defining URIs. A wild card is created using mustache syntax {id}.
Router::delete('/user/{id}',['use'=>'UserController@delete']);This declared Route will match a DELETE request http://localhost/user/10.
Each wild card will be injected as argument to the defined Closure or Controller.
Router::delete('/user/{id}',function(Request $request,$id){
Users::delete('id',$id);
});Once the routes have been parsed and validated, the application will capture the incoming request and save all the information in the Request instance.
The Request instance contains the information regarding the incoming request, represented with Parameter instances.
The Request instance has the following accessible properties.
public $method; // Request method | String
public $attributes; //The request attributes parsed from the PATH_INFO | Parameter
public $request; //Request body parameters ($_POST). | Parameter
public $query; //Query string parameters ($_GET). | Parameter
public $server; //Server and execution environment parameters ($_SERVER). | Parameter
public $files; //Uploaded files ($_FILES). | Parameter
public $cookies; //Cookies ($_COOKIE). | Parameter
public $headers; //Headers (taken from the $_SERVER). | Parameter
public $content; //The raw Body data | StringEach Parameter instance will have the following accessible methods:all,keys,replace,add,get,set,has,remove.
public function all(): array //Returns the parameters.
public function keys(): array //Returns the parameter keys.
public function replace(array $parameters = array()) //Replaces the current parameters by a new set.
public function add(array $parameters = array()) //Add parameters.
public function get(string $key, $default = null) //Returns a parameter by name, or fallback to default.
public function set(string $key, $value) //Sets a parameter by name.
public function has(string $key): bool //Returns true if the parameter is defined.
public function remove(string $key) //Removes a parameter./* http://localhost/info?beans=10 */
Router::get('/info',function(Request $request){
return $request->query->has('beans') ?
json_encode($request->query->get('beans')) : [];
});Route requests are managed by Controllers. A Controller extend the Controller class and should look like this.
class CustomController extends Controller{
public function index(Request $request){
/*Code goes here*/
return json_encode($request->content);
}
} A
Controllerthat is specified inroutes.phpmust exists, otherwise the application will not start.
If the Controller returns a json or text , you can use the Response instance.
It will set the correct Content-type and return the desired data format, currently the implemented have the following Content-types:
application/jsonwithjsonstatic method.text/plainwithtextstatic method.
/**
* Return the desired HTTP code with json
*/
public static function json($content=[],int $status=self::HTTP_OK,$flags=JSON_FORCE_OBJECT|JSON_NUMERIC_CHECK)
/**
* Return the desired HTTP code with text
*/
public static function text(string $content='',int $status=self::HTTP_OK)
/**
* Return the desired HTTP code
*/
public static function code(int $status=self::HTTP_OK)If the Controller returns a Web page, then the View class should be required and returned as response with the desired data.
class CustomController extends Controller{
public function index(Request $request){
$keys = $request->query->keys();
return new View('home.php',compact('keys'));
}
}The abstract Model class allows the mapping of Relational Database to Objects without the need to parse data manually.
Subclasses of Model can perform basic CRUD operations and access the entity relationship as simple as calling a method.
Those operations are provided using the
Databaseclass from theQuery Builder Project.
The Model must support One to One, One to Many and Many to Many relationships.
/**
* Create a one to one relationship
* @param The Entity name is the class name to map the results to
* @returns The Model The one to one entity for the current instance
* */
public function hasOne(EntityName)/**
* Creates a one to many relationship
* @param The Entity name is the class name to map the results to
* @returns An array of the one to many entities for the current instance
* */
public function hasMany(EntityName)/**
* Commits to database the current changes to the entity
* @returns bool Success status
* */
public function save():bool/*E.g.*/
$todo = Todo::first('id', 1);
$todo->title = "New title";
$todo->save(); /**
* Deletes the current instance from the database
* @returns bool Success status
* */
public function delete():bool/*E.g.*/
$todo = Todo::first('id', 1);
$todo->delete();/** Configures the database connection */
public static function configConnection($host, $dbName, $user, $password)/*E.g.*/
Model::configConnection(
'host',
'db_name',
'user',
'pass'
); /** Deletes from the database using the input condition */
public static function destroy($col1, $exp, $col2): bool/*E.g.*/
Todo::destroy('id', '=', 1);/**
* Query the first result of a table using column value pair
* @returns Model of first query result
*/
public static function first($col1, $col2)/*E.g.*/
Todo::first('id', 1);/**
* Query the entity result of a table using expression
* @returns An array of Model from the query result
*/
public static function find($col1, $col2)/*E.g.*/
Todo::find('id', 1);/**
* Query all the table values
* @returns An array of mapped entities
*/
public static function all()/*E.g.*/
Todo::all();/**
* Applies a where condition to the table
* @returns Statement Query Statement using the Model table
*/
public static function where($col1, $exp, $col2)/*E.g.*/
Todo::where('title', '=', 'My title')->get()/**
* The whereRaw method can be used to inject a raw "where" clause into your query.
* @returns Statement Query Statement using the Model table
*/
public static function whereRaw($str)/*E.g.*/
Todo::whereRaw("title = 'My title'")->get()/**
* The whereIn method verifies that a given column's value is contained within the given array
* @returns Statement Query Statement using the Model table
*/
public static function whereIn($col, $values)/*E.g.*/
Todo::whereIn('id', [1,2,3])->get()/**
* The whereNotIn method verifies that the given column's value is not contained in the given array
* @returns Statement Query Statement using the Model table
*/
public static function whereNotIn($col, $values)/*E.g.*/
Todo::whereNotIn('id', [1,2,3])->get()/**
* The whereNull method verifies that the value of the given column is NULL
* @returns Statement Query Statement using the Model table
*/
public static function whereNull($col)/*E.g.*/
Todo::whereNull('updated_at')->get()/**
* The whereNotNull method verifies that the column's value is not NULL
* @returns Statement Query Statement using the Model table
*/
public static function whereNotNull($col)/*E.g.*/
Todo::whereNotNull('updated_at')->get()/**
* The whereBetween method verifies that a column's value is between two values
* @returns Statement Query Statement using the Model table
*/
public static function whereBetween($col, $value1, $value2)/*E.g.*/
Todo::whereBetween('day', 1, 5)->get()/**
* The whereBetween method verifies that a column's value is not between two values
* @returns Statement Query Statement using the Model table
*/
public static function whereNotBetween($col, $value1, $value2)/*E.g.*/
Todo::whereNotBetween('day', 1, 5)->get()/**
* The whereColumn method may be used to verify that two columns are equal
* @returns Statement Query Statement using the Model table
*/
public static function whereColumn($col, $value1, $value2)/*E.g.*/
Todo::whereColumn('first_name', 'last_name')->get()/**
* You may also pass a comparison operator to the whereColumn method
*//*E.g.*/
Todo::whereColumn('updated_at', '>', 'created_at')->get()/**
* You may also pass an array of column comparisons to the whereColumn method.
* These conditions will be joined using the 'and' operator.
*//*E.g.*/
Todo::whereColumn([
['first_name', '=', 'last_name'],
['updated_at', '>', 'created_at'],
])->get()/**
* Creates a new value in the database using data array (column + value)
* @returns Model The new instance on success
*/
public static function create(array $data)/*E.g.*/
Todo::create(['title' => 'My title']);/**
* Select columns from table (if $columns array is empty select automatically all the columns)
* @returns Statement Select Statement
*/
public static function select(array $columns=[])/*E.g.*/
Todo::select()->get()/**
* Inner join
* @returns Statement Query Statement using the Model table
*/
public function innerJoin($modelClassName, string $col1, string $exp, string $col2, $and=false)/*E.g.*/
Article::select(['author.id', 'article.id'])->innerJoin(Author::class,"author_id","=","id")->get();/**
* Cross join
* @returns Statement Query Statement using the Model table
*/
public function crossJoin($modelClassName, $and=false)/*E.g.*/
Article::select(['author.id', 'article.id'])->crossJoin(Author::class)->get();/**
* Left join
* @returns Statement Query Statement using the Model table
*/
public function leftJoin($modelClassName, string $col1, string $exp, string $col2, $and=false)/*E.g.*/
Article::select(['author.id', 'article.id'])->leftJoin(Author::class,"author_id","=","id")->get();/**
* Right join
* @returns Statement Query Statement using the Model table
*/
public function rightJoin($modelClassName, string $col1, string $exp, string $col2, $and=false)/*E.g.*/
Article::select(['author.id', 'article.id'])->rightJoin(Author::class,"author_id","=","id")->get();/**
* Full join example
*/
public function fullJoin($modelClassName, string $col1, string $exp, string $col2, $and=false)/*E.g.*/
Article::select(['author.id', 'article.id'])->fullJoin(Author::class,"author_id","=","id")->get();/**
* Make attention ALL not static where methods has one more parameter $and to use AND operator
* public function where($col1, $exp, $col2, $and=false)
* public function whereRaw($str, $and=false)
* public function whereIn($col, $values, $and=false)
* public function whereNotIn($col, $values, $and=false)
* public function whereNull($col, $and=false)
* public function whereNotNull($col, $and=false)
* public function whereBetween($col, $value1, $value2, $and=false)
* public function whereNotBetween($col, $value1, $value2, $and=false)
*/
Todo::where('title','=','MyTitle')->where('genre', '=', 'mystery', true)->get();/**
* To use multiple where clauses with OR operator
* public function orWhere($col1, $exp, $col2)
* public function orWhereRaw($str)
*/
Todo::where('title','=','MyTitle')->orWhere('genre', '=', 'mystery')->get();
Todo::where('title','=','MyTitle')->orWhereRaw("genre = 'mystery'")->get();To enable relationship mapping, create a method to return the entities and use the following methods allow to map the
entity relationship to the current instance. The methods take the className as parameter.
One to One
class Article { //The author of an article
public function author() {
return $this->hasOne('User');
}
}One to Many and Many to Many
class User { //The author articles
public function articles() {
return $this->hasMany('Articles');
}
}Before making any queries it is necessary to setup database configuration using static function configConnection.
Model::configConnection('host', 'dbName', 'user', 'password');Model class cannot be instantiated as it is, but must be extended by another class
- The table name is inferred from the class
ClassNameto tableclass_nameby converting fromPascalCasetosnake_case.- Foreign keys must follow the
<table>_idname convention.- Entity tables must have an
idfield.- In order to work, mapped tables must have an
idcolumn
Extend Model to map an existing table to the class.
class Author extends Model {
/*Code goes here*/
}Class Author is mapped to a table named author.
Now static and instance methods can be called on Author.
// Create a new record in author table
$author = Author::create([
// id is not specified as it has auto increment
'name' => 'Satoshi',
'username' => 'john123'
]);
// Update
$author->name = 'Nakamoto';
$author->save();
// Delete
$author->delete();
// or
Author::delete('name', '=', 'John');CREATE TABLE tweet (
id INT(8) UNSIGNED AUTO_INCREMENT PRIMARY KEY,
content VARCHAR(280) NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
);
CREATE TABLE comment (
id INT(8) UNSIGNED AUTO_INCREMENT PRIMARY KEY,
tweet_id INT(6) UNSIGNED,
content VARCHAR(280) NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (tweet_id) REFERENCES tweet(id) ON DELETE NO ACTION ON UPDATE NO ACTION
);