Basic PHP Framework
About
A full-stack PHP framework that gives you the basics for starting a web project.
PHP 8 is required.
Key Features
- MVC architecture
- Simple routing
- View templates using Plates
- Class auto loading and namespaces
- Dependency Injection Container
- PDO database wrapper class
Documentation
- Installation
- Routing
- Controllers
- Dependency Injection Container
- Views
- Models and Database
- Helper Functions
- Environmental and Configuration Data
- CLI Tools
Installation
Download using Composer.
composer create-project connora/basic your-project-name
The project .env
file should be created on install when using composer. An .env.example
file is included as well.
Serving Your Site Locally
If you want to serve your site locally for quick testing or development and you have PHP installed on your machine, use the "serve" command while working in the root of your project.
Note: this will only serve your site with php, not MySQL.
php basic serve
Alternatively, use Laragon, Docker, or XAMPP with a vhost configuration.
Routing
Registering
By default, you will register your routes within /routes/main.php
.
You will have access to the $this->router
property which supplies an instance of the App\Core\Router
class.
The App\Core\Router
class offers the following register methods that align with the common HTTP verbs:
$this->router->get($uri, $callback);
$this->router->post($uri, $callback);
$this->router->put($uri, $callback);
$this->router->patch($uri, $callback);
$this->router->delete($uri, $callback);
These methods will register your routes as valid endpoints within your application. If you try to access a url/route that isn't registered, a 404
page will be displayed.
Callback Functions
The route's callback will either be a closure (an anonymous function) where you can execute your endpoint logic directly, or a reference to a controller method using an array, where the first item is the fully qualified class you want to reference, and the second item is the method name that will be called.
// Basic route using a closure
$this->router->get('/home', function () {
return 'Hello World';
});
// Alternatively, use a controller class and a method to store your logic in
$this->router->get('/home-alt', [HomeController::class, 'index']);
Dynamic Parameters
You can set dynamic parameters in your routes uri by prefixing the slug with a hashtag #
. The parameter's value will be available as an argument variable via your callback function.
Using within a controller:
// Ex: yoursite.com/blog/post/123
// within /routes/main.php
$this->router->get('/blog/post/#id', [BlogPostController::class, 'show']);
// within /app/controllers/BlogPostController.php
public function show($id)
{
// $id = '123'
return View::render('pages.blog.post.single', [
'blogPostData' => $this->blogPostModel->findById($id)
]);
}
Or using a basic closure:
$this->router->get('/example/#id', function ($id) {
return $id
});
Batch Registering
You can register routes in batches that share similar qualities, such as a controller, a uri prefix, or both. The intent here is to reduce boilerplate code, and provide better organization.
When batching routes, it's required to chain on the batch()
method at the end, which accepts a closure argument containing the routes you want the batch properties to apply to.
Batch related methods available to you:
$this->router->controller(string $className);
$this->router->prefixUri(string $uri);
$this->router->batch(callable $closure);
When batching routes with the prefixUri()
method, the routes within the closure will all be prepended by your defined uri prefix.
For example:
$this->router
->prefixUri('/users')
->batch(function () {
$this->router
// /users GET (show all users)
->get('/', [UserController::class, 'index'])
// /users/create GET (form to create a user)
->get('/create', [UserController::class, 'create'])
// /users POST (endpoint to store a new user)
->post('/', [UserController::class, 'store'])
// /users/123 GET (show a single user)
->get('/#id', [UserController::class, 'show'])
// /users/123/edit GET (form to edit user properties)
->get('/#id/edit', [UserController::class, 'edit'])
// /users/123 PATCH (endpoint to update user properties)
->patch('/#id', [UserController::class, 'update'])
// /users/123 DELETE (endpoint to remove a user record)
->delete('/#id', [UserController::class, 'destroy']);
});
When batching routes with the controller()
method, take note that:
- The register method's second argument inside the closure will be a string referencing the endpoint method, instead of the default array syntax
- The
$this->router->view()
method is unavailable within the batch closure, since we are required to reference a controller method
For example:
$this->router
->controller(UserController::class)
->batch(function () {
$this->router
->get('/users', 'index')
->get('/users/create', 'create')
->post('/users', 'store')
->get('/users/#id', 'show')
->get('/users/#id/edit', 'edit')
->patch('/users/#id', 'update')
->delete('/users/#id', 'destroy');
});
Or use both:
$this->router
->controller(UserController::class)
->prefixUri('/users')
->batch(function () {
$this->router
->get('/', 'index')
->get('/create', 'create')
->post('/', 'store')
->get('/#id', 'show')
->get('/#id/edit', 'edit')
->patch('/#id', 'update')
->delete('/#id', 'destroy');
});
Note: you cannot nest a
batch()
method within itself.
Form Requests
The standard html <form>
tag only accepts GET
and POST
as valid request methods. We can overcome this by using the method_spoof(string $method)
helper function. This requires our form to use the POST
method request and to specify the "spoofed" method inside the form using PUT
, PATCH
, or DELETE
.
For example:
$this->router->patch('/update-example', [ExampleClass::class, 'updateMethod']);
<!-- Example form request to update data -->
<form action="/update-example" method="POST">
<?= csrf() ?>
<?= method_spoof('PATCH') ?>
<label class="form-label">Field to update</label>
<input name="exampleField" value="<?= $originalValue ?>" required>
<button type="submit" name="updateSubmit">Update</button>
</form>
It's also recommended to use the included csrf()
and csrf_valid()
helper functions to ensure your requests are safe from any potential Cross Site Request Forgery.
Organization
As your application grows, you will probably want to better organize your routes instead of having them all in the /routes/main.php
file. By default, you have access to the $this->router
property within any PHP file that resides inside of the /routes
directory. So feel free to organize any file/folder structure you wish!
Controllers
The Basics
Controllers are classes meant to handle the logic for an incoming HTTP request. Make sure to set your controller methods access modifiers to public
so the router can execute them successfully.
It is best practice to only handle the user input, and response logic within your controller methods. If you have more complicated business logic involved in your endpoint, it's recommended to abstract that into another class (typically a Service
class).
Controllers should be named and organized based on the subject matter the request is pertaining too. Is is also recommended, but not required to include the word "Controller" in your class name.
There is an example controller class provided.
Note: Controller methods should only accept dynamic route parameter arguments, the included dependency injection container will only resolve classes established in the constructor.
Requests / User Input
The framework provides a default App\Core\Request
class that can be used to interact with user input within your controllers. The class will provide basic sanitation on the incoming request inputs, and methods for interacting with the data, rather than using PHP's super globals directly.
Available App\Core\Request
methods:
/**
* Return an array of all sanitized inputs from the request
*/
$request->all();
/**
* Return the sanitized $_GET value by it's key, optional default value
*/
$request->get(string $key, string $default = null);
/**
* Return the sanitized $_POST value by it's key, optional default value
*/
$request->post(string $key, string $default = null);
/**
* Return the sanitized $_REQUEST value by it's key, optional default value
*/
$request->input(string $key, string $default = null);
The App\Core\Request
class can be instantiated within each controller method that needs it, or by using the the included request()
helper function:
<?php
namespace App\Controllers;
use App\Core\Request;
class ExampleController
{
public function update()
{
// standard instantiation
$request = new Request();
$data = $request->input('data');
// or with the helper
$data = request()->input('data');
}
}
Dependency Injection Container
By default, you can type hint any class into a controller's __construct()
method to have the container build out the class and it's dependencies for you. The container will use reflection and recursion to automatically instantiate and set all the needed dependencies your classes may have.
<?php
namespace App\Controllers;
use App\Core\View;
use App\Models\UserModel;
class UserController
{
private $userModel;
// utilizing the containers automatic resolution
// by type hinting the class we want
public function __construct(UserModel $userModel)
{
$this->userModel = $userModel;
}
public function index()
{
$users = $this->userModel->getAll();
return View::render('pages.users.list', ['users' => $users]);
}
}
App\Core\Container
class methods available to you:
$this->container->get(string $id);
$this->container->set(string $id, callable $callback);
$this->container->setOnce(string $id, callable $callback);
By default the container is used to easily instantiate a class you need, without you having to worry about instantiating it's dependencies. However, In certain situations your classes may not be resolvable by the container (requiring primitive constructor arguments, etc.), or perhaps you need a more custom implementation of the class returned from the container.
To manually set a class binding into the container, use the set()
method, passing in a string reference $id
(usually the fully qualified class name), and a closure as the $callback
which should return the new class instance. Whenever your set class needs to be resolved by the container, it will use your registered callback to return it's implementation.
If you want your configured class to only be instantiated once, and used in all the subsequent references in the container, you can use the setOnce()
method. There are a few core classes that are set once by default for use throughout the framework.
To return/resolve a class instance from the container, use the get()
method. This is what the container uses internally when using automatic resolution with your injected classes.
When setting up your manual bindings, you can access the container within the closure using the $container
argument. This allows you to use $container->get()
inside the closure to resolve any dependencies for the class you are returning.
If you need to manually set up a class or interface and it's binding, you may do so in the App\Core\App
class containerSetup()
method:
/**
* Establish any container class bindings for the application
*/
public function containerSetup(): self
{
// included by default
$this->container->setOnce(Config::class, function ($container) {
return new Config($_ENV);
});
$this->container->setOnce(Request::class, function ($container) {
return new Request();
});
$this->container->setOnce(DB::class, function ($container) {
$dbConfig = config('database', 'main');
return new DB(
$dbConfig['name'],
$dbConfig['username'],
$dbConfig['password'],
$dbConfig['host']
);
});
// reference the actual repository whenever the interface is referenced/injected
$this->container->set(UserRepositoryInterface::class, function ($container) {
return new UserRepository($container->get(UserModel::class));
});
return $this;
}
If you don't want to resolve certain classes in your controller's constructor, you can use the included container()
helper function to access the get()
method.
<?php
namespace App\Controllers;
use App\Core\View;
use App\Models\UserModel;
class ExampleController
{
public function index()
{
// get/resolve the UserModel class from the container without injecting into the constructor
// dependencies automatically resolved, pretty neat
$userModel = container(UserModel::class);
$user = $userModel->getById(request()->input('id'));
return View::render('pages.example', ['user' => $user]);
}
}
Note: You are not required to use the included DI Container. Feel free to manually instantiate your classes and pass them around wherever needed using traditional dependency injection techniques.
Views
By default, the framework uses Plates for it's view template system. The App\Core\View
class is used as a basic wrapper.
Static Page?
The App\Core\Router
class also has a method for calling your view directly, so you don't have to bother with closures or controllers for your more simple pages:
$this->router->view('/', 'pages.welcome');
In Your Controller Method
When referencing your view within a controller, use the static App\Core\View::render()
method to return the template content. The method accepts the view file reference (using a period .
as the nesting delimiter, no file extension) and an array of data variables you want accessible in the view.
public function index()
{
$foo = 'bar';
return View::render('pages.example', ['foo' => $foo]);
}
Models and Database
Models are classes that are meant to interact with your database.
The DB Class
The included App\Core\DB
class acts as a wrapper around PDO and is intended to make connecting to a database and executing your queries easier. As mentioned earlier, the App\Core\DB
class is set once into the container by default using the database.main
configuration settings. You can change the default options, or setup multiple connections using /config/database.php
and your application's .env
file, more on that later.
Establishing a Connection
There are multiple approaches to creating and using a database connection within a PHP web application.
As stated previously, the App\Core\DB
class is what creates our database connection. There is a default container binding added within: App\Core\App::containerSetup()
, it is commented out by default. With this approach, we can ensure that there is only one database class/connection created per request lifecycle, and it can be easily referenced in the application whenever it is needed.
To make things easier, your model classes should extend the included App\Core\Model
abstract class to include the $this->db
property.
<?php
namespace App\Models;
use App\Core\Model;
/**
* $this->db available to use for your queries
*/
class ExampleModel extends Model
{
private $table = 'example';
public function getAll()
{
$sql = "SELECT * FROM $this->table";
return $this->db->query($sql);
}
}
<?php
namespace App\Controllers;
use App\Core\View;
use App\Models\ExampleModel;
class ExampleController
{
private $exampleModel;
public function __construct(ExampleModel $exampleModel)
{
$this->exampleModel = $exampleModel;
}
public function index()
{
return View::render('pages.example', [
'data' => $this->exampleModel->getAll();
]);
}
}
However, this may not be the approach you want/need in your application, so feel free to remove the binding or use more traditional dependency injection techniques for your database/models:
<?php
namespace App\Controllers;
use App\Core\DB;
use App\Models\ExampleModel;
class ExampleController
{
private $db;
public function __construct(DB $db)
{
// Ex.1
// Use the main DB connection that is configured in the container
// via the type-hinted constructor argument
$this->db = $db;
// Ex.2
// Create an alternative DB class binding in the container
// Useful for models that need a different database connection
$this->db = container('db_alt');
// Ex.3
// Create a connection on the fly
$dbConfig = config('database', 'alt');
$this->db = new DB(
$dbConfig['name'],
$dbConfig['username'],
$dbConfig['password'],
$dbConfig['host']
);
}
public function index()
{
$exampleModel = new ExampleModel($this->db);
return View::render('pages.example', [
'data' => $exampleModel->getAll();
]);
}
}
The App\Core\DB
class offers the following methods:
/**
* Get the established PDO connection
*/
$this->db->pdo();
/**
* Prepares the query, binds the params, executes, and runs a fetchAll()
*/
$this->db->query(string $sql, array $params = []);
/**
* Prepares the query, binds the params, executes, and runs a fetch()
*/
$this->db->single(string $sql, array $params = []);
/**
* Prepares the query, binds the params, and executes the query
*/
$this->db->execute(string $sql, array $params = []);
Example
<?php
namespace App\Models;
use App\Core\Model;
class UserModel extends Model
{
private $table = 'users';
public function getAll()
{
$sql = "SELECT * FROM $this->table";
return $this->db->query($sql);
}
public function getById($id)
{
$sql = "SELECT * FROM $this->table WHERE id = ?";
return $this->db->single($sql, [$id]);
}
public function getByEmail($email)
{
$sql = "SELECT * FROM $this->table WHERE email = ?";
return $this->db->single($sql, [$email]);
}
public function create($name, $email, $username, $password)
{
$hashedPwd = password_hash($password, PASSWORD_DEFAULT);
$sql = "INSERT INTO $this->table(name, email, username, password)
VALUES(:name, :email, :username, :password)";
return $this->db->execute($sql, [
'name' => $name,
'email' => $email,
'username' => $username,
'password' => $hashedPwd,
]);
}
public function update(int $userId, array $properties)
{
$setString = '';
foreach ($properties as $property => $value) {
$setString .= $property . ' = ' . ':' . $property;
if ($property != array_key_last($properties)) {
$setString .= ', ';
} else {
$setString .= ' ';
}
}
$properties['id'] = $userId;
$sql = "UPDATE $this->table
SET $setString
WHERE id = :id";
return $this->db->execute($sql, $properties);
}
public function delete(int $userId)
{
$sql = "DELETE FROM $this->table WHERE id = ?";
return $this->db->execute($sql, [$userId]);
}
}
To follow MVC conventions, and for better organization in your application, it is highly recommended to only use the DB class and execute queries within your model classes.
Helper Functions
Helper functions are meant to be accessed anywhere within the application. There are few included with the framework, feel free to add our own as well.
/app/helpers.php
Environmental and Configuration Data
.env
File
The The project .env
file should be created on install when using composer. If not, a provided example file is included to create a new one.
This file is for your settings variables that may differ from each environment your site is being used (local, staging, production), as well as storing private information you don't want committed to your source code repository, such as API keys, database access credentials, etc. It is added to the .gitignore
by default.
# Site Environment
ENV=local
# When the data has spaces
EXAMPLE_DATA="Example Data"
Configuration Data
Configuration data can be thought of as your site "settings". This could include database connections, mail server info, site meta data, etc. This data will be stored in multi-dimensional arrays using .php
files residing in the /config
directory.
When you need to set configuration settings that are related to your private .env
data, you can use the $this->env
property to access it's values.
The config(string $file, string $key)
helper function is used to access the desired data throughout the application. Using the config file reference as the first argument (without the file extension), and the value key location string as the second argument (using a period .
as the nesting delimiter for accessing the nested values in the configuration array).
// get the main database connection host name
$host = config('database', 'main.host');
CLI Tools
Create a controller:
php basic new:controller YourControllerName
Create a model:
php basic new:model YourModelName
Serve your site locally:
php basic serve