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=app
The 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 -d
Checking if the application is running correctly using:
docker ps
you should see three containers running the services..
Stopping the application:
docker compose down
NGINX 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 -d
Docker 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 ps
you should see three containers running the three services.
To stop the application run:
docker compose down
The docker-compose.yml
is used to create a dev environment with PHP-8.2 FPM, NGINX and MySQL server.
Notice
./docker/php-fpm/Dockerfile
used to create a php-fpm image indocker-compose.yml
php-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:
-
Method
The method that the route should respond, it can be:
POST,GET,PATCH or DELETE
. -
URI
The Uniform Resource Identifier that the route should respond, it can be anything separated from
/
. -
Action
The Action is what the route must do when called, it can be tree things:
Closure
PHP Anonymous FunctionString
Simple StringArray
Must have the associationuse
pointing to an existingController
andmethod
separated 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:
-
String
It should get directly printed.
-
Closure
The Closure should be executed passing as first argument the
Request
object. -
Array
The specified
Method
in the specifiedController
should be called, passing as first argument theRequest
object.
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 | String
Each 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
Controller
that is specified inroutes.php
must 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/json
withjson
static method.text/plain
withtext
static 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
Database
class 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
ClassName
to tableclass_name
by converting fromPascalCase
tosnake_case
.- Foreign keys must follow the
<table>_id
name convention.- Entity tables must have an
id
field.- In order to work, mapped tables must have an
id
column
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
);