This is a continuation of part 1 of the blog. Hopefully you've already finished that part of the blog first before we go onto some sweet improvements!
Sweet Improvements
Now that we have a basic system in place, we need a few other enhancements to really make this system shine.
Controller improvements
See how it's a little annoying that the construct is the same for each of the controllers we created? We could actually create a simple abstract class to help clean up some of the code.
<?php
// app/controllers/BaseController.php
declare(strict_types=1);
namespace app\controllers;
use flight\Engine;
use flight\net\Request;
use flight\net\Response;
use Ghostff\Session\Session;
use flight\database\PdoWrapper;
/**
* These help with IDE autocompletion and type hinting if you
* use the shortcut method.
*
* @method Request request()
* @method Response response()
* @method Session session()
* @method PdoWrapper db()
* @method string getUrl(string $route, array $params = [])
* @method void render(string $template, array $data = [])
* @method void redirect(string $url)
*/
abstract class BaseController
{
/** @var Engine */
protected Engine $app;
/**
* Constructor
*/
public function __construct(Engine $app)
{
$this->app = $app;
}
/**
* Call method to create a shortcut to the $app property
*
* @param string $name The method name
* @param array $arguments The method arguments
*
* @return mixed
*/
public function __call(string $name, array $arguments)
{
return $this->app->$name(...$arguments);
}
}
That __call()
method is a little helpful shortcut so you don't have to do $this->app->something()
in your controllers, you can instead do $this->something()
and it "forwards" it to $this->app->something()
. There's also reference to a Session class in there that we'll talk about in juuuuuust a bit. Now we can clean up the controller code a little bit. Here's the HomeController.php
with the cleaned up code.
<?php
declare(strict_types=1);
namespace app\controllers;
class HomeController extends BaseController
{
/**
* Index
*
* @return void
*/
public function index(): void
{
$this->render('home.latte', [ 'page_title' => 'Home' ]);
}
}
Cleaner right? You can apply this same set of changes to the PostController
and CommentController
.
Route Cleanup
You may have noticed we hard coded the routes in our little app. We can use route aliases to change that behavior so more flexible in the future (especially with such a new blog, anything can change at this point!)
So let's add an alias to our home route:
$router->get('/', \app\controllers\HomeController::class . '->index')->setAlias('home');
Then we have a couple options to generate the URLs from the alias. In some controller methods you'll notice there is a $this->app->redirect('/blog');
statement. To generate the URL from the alias you could do the following instead:
// after we setup the 'blog' alias
$url = $this->app->getUrl('blog');
$this->app->redirect($url);
If you need to add a method in your Latte code to generate the URL for you, we could change how Latte
is setup.
$Latte = new \Latte\Engine;
$Latte->setTempDirectory(__DIR__ . '/../cache/');
// This adds a new function in our Latte template files
// that allows us to generate a URL from an alias.
$Latte->addFunction('route', function(string $alias, array $params = []) use ($app) {
return $app->getUrl($alias, $params);
});
$app->map('render', function(string $templatePath, array $data = [], ?string $block = null) use ($app, $Latte) {
$templatePath = __DIR__ . '/../views/'. $templatePath;
$Latte->render($templatePath, $data, $block);
});
And now that you have the code in place, when you're in your template files, you can do something like this:
<!-- this was /blog/{$post->id}/edit -->
<a class="pseudo button" href="{route('blog_edit', [ 'id' => $post->id ])}">Update</a>
Why do we do this when it seems like it takes longer to create the url?
Well some software platforms need to adjust their URLs (because marketing or product said so). Let's say you've hard coded everything so that the blog page is the /blog
URL and marketing says they want another blog page, but the current blog page URL they want as a different page for a different market segment. In this case, let's say they want it to be changed from /blog
to /blog-america
. If you used aliases, all you would have to do is change the route URL in one spot (the routes.php
file), and everywhere around your codebase it will update to the new URL. Pretty nifty right? If you want to have some fun, now that you've added an alias for /blog/@id/edit
, why don't you change the URL part to /blog/@id/edit-me-i-dare-you
. Now when you refresh the page, you'll see that because of the ->getUrl('blog_edit')
code you're using, your URL's just updated themselves and when you click on the new URL is just works! Sweet!
Added bonus, you'll notice with the comment aliases we use that we only do this:
<a class="pseudo button" href="{route('comment_destroy', [ 'comment_id' => $comment->id ])}">Delete</a>
We should need to also specify @id
right cause the route is $router->get('/@id/comment/@comment_id/delete', /* ... */);
? Well, when getUrl()
is called, it will take a peek at your current URL and if it sees that your current URL has an @id
in it, it will automatically add that to the URL for you. So you don't have to worry about it!
Authentication with Middleware
You don't want anyone in the world to be able to update your blog right? So we'll need to implement a simple login mechanism to make sure that only logged in people can add to or update the blog. If you're remembering from up above, we need to add a route, either a new controller and a method or a method in an existing controller, and a new HTML file. Let's create the routes we'll need first:
// Blog
$router->group('/blog', function(Router $router) {
// Login
$router->get('/login', \app\controllers\LoginController::class . '->index')->setAlias('login');
$router->post('/login', \app\controllers\LoginController::class . '->authenticate')->setAlias('login_authenticate');
$router->get('/logout', \app\controllers\LogoutController::class . '->index')->setAlias('logout');
// Posts
// your post routes
});
Now let's create new controllers with runway
:
php runway make:controller Login
php runway make:controller Logout
And you can copy the code below into your LoginController.php
:
<?php
declare(strict_types=1);
namespace app\controllers;
class LoginController extends BaseController
{
/**
* Index
*
* @return void
*/
public function index(): void
{
$this->render('login/index.latte', [ 'page_title' => 'Login' ]);
}
/**
* Authenticate
*
* @return void
*/
public function authenticate(): void
{
$postData = $this->request()->data;
if($postData->username === 'admin' && $postData->password === 'password') {
$this->session()->set('user', $postData->username);
$this->session()->commit();
$this->redirect($this->getUrl('blog'));
exit;
}
$this->redirect($this->getUrl('login'));
}
}
And copy the below code for your LogoutController.php
:
<?php
declare(strict_types=1);
namespace app\controllers;
class LogoutController extends BaseController
{
/**
* Index
*
* @return void
*/
public function index(): void
{
$this->session()->destroy();
$this->redirect($this->getUrl('blog'));
}
}
Well wait a minute here, the controller is now calling a session()
method. We'll need to register a new service in our services.php
file to handle sessions. Thankfully there's a great session handler that we can use! First we'll need to install it with composer:
composer require ghostff/session
Now we can add the session service to our services.php
file:
// Session Handler
$app->register('session', \Ghostff\Session\Session::class);
Do you see that
$this->session->commit();
line in the controller? This is a very intentional design decision by the library author for a very good reason. Long story short, in PHP there is such a thing assession_write_close()
which writes the session data and closes the session.
This is important because if you have a lot of AJAX requests, you don't want to lock the session file for the entire time the request is being processed. That might sound pointless to you, but what if one of your ajax requests (or page loads) is running a very large report that might take 10-20 seconds to run? During that time you will not be able to make another request on the same site because the session in locked. Yikes!
The commit()
method is a way to write the session data and close the session. If you don't call commit()
the session will actually never be written to, which can be annoying, but what's more annoying is having long requests hang (like a report running) that causes your entire app to appear to freeze up!
Now we can create the HTML file for the login page. Create a new file at app/views/login/index.latte
and put in the following code:
{extends '../layout.latte'}
{block content}
<h1>Login</h1>
<form action="{route('login_authenticate')}" method="post">
<p>Login as whoever! And the password is always "password" (no quotes)</p>
<label for="username">Username:</label>
<input type="text" name="username" id="username" required>
<label for="password">Password:</label>
<input type="password" name="password" id="password" required>
<button type="submit">Login</button>
</form>
{/block}
Lastly, we just need a link to our new login page (and a logout one while we're at it)! Let's add a link to the app/views/blog/index.latte
file just after the {/foreach}
loop:
<p><a href="{route('login')}">Login</a> - <a href="{route('logout')}">Logout</a></p>
Umm...wait, so we can login as anyone?! That's not very secure and you're right! We're only doing this for demonstration purposes. Of course we would want to have a system where we use password_hash('my super cool password', PASSWORD_BCRYPT)
to hash the password and then store that in the database. But that's a topic for another blog post!
So we now have a login system in place. But what if we want to make sure that only logged in users can access the blog? We can use middleware to help us with that! A middleware is a piece of code that runs before or after a route. We can use middleware to check if a user is logged in and if they are not, we can redirect them to the login page. Let's create a new file at app/middlewares/LoginMiddleware.php
:
<?php
declare(strict_types=1);
namespace app\middlewares;
use flight\Engine;
class LoginMiddleware {
/** @var Engine */
protected Engine $app;
public function __construct(Engine $app)
{
$this->app = $app;
}
public function before(): void
{
if ($this->app->session()->exist('user') === false) {
$this->app->redirect($this->app->getUrl('login'));
}
}
}
Now that we are able to use sessions, we actually can inject them as globals into our templates. Let's go to the services.php
file and change our render
method to the following:
$app->map('render', function(string $templatePath, array $data = [], ?string $block = null) use ($app, $Latte) {
$templatePath = __DIR__ . '/../views/'. $templatePath;
// Add the username that's available in every template.
$data = [
'username' => $app->session()->getOrDefault('user', '')
] + $data;
$Latte->render($templatePath, $data, $block);
});
Note: One thing that's important when injecting a variable at this point is that it becomes "global". Global variables in general are bad if they are not injected properly. Don't inject the whole session, but instead inject the specific variable you need. In this case, we only need the username. Globals can be bad, you've been warned!
Now we can run checks in our templates to see if the user is logged in and if we want to show certain elements or not. For example, in the app/views/blog/index.latte
file, we can add the following code:
{if $username}
<p><a class="button" href="{route('blog_create')}">Create a new post</a></p>
{/if}
If the user isn't logged in, that button won't show! Pretty cool eh?
But wait, there's more!
So, wait, remember that handy dandy debug bar called Tracy? There's an extension for it that we can take advantage of to inspect our session variables. Let's go to the config.php
file and change a line of code:
(new TracyExtensionLoader($app));
// change it to...
(new TracyExtensionLoader($app, [ 'session_data' => (new \Ghostff\Session\Session)->getAll() ]));
While we're at it, you get a free bonus of a built in Latte extension (cause you've read all the way to down here!). Go to your services.php
file and add this line after the $Latte->setTempDirectory(__DIR__ . '/../cache/');
line:
// PHP 8+
$Latte->addExtension(new Latte\Bridges\Tracy\TracyExtension);
// PHP 7.4
Latte\Bridges\Tracy\LattePanel::initialize($Latte);
Here's some pictures of the various Tracy Extensions:
See how helpful it is to see the query that's run, and to see where the query was ran so you can trace it down???
Here's Latte to help you know the template you're using.
And here's the session data. Obviously if we had more session data this would be more useful.
Permissions
Now that we can see that we are logged in or not, maybe there are more fine grained permissions that we need to look at. For instance, can only certain people after logging in edit a blog post? Can certain people delete?
Now we can create user roles for your blog such as user
(anyone can view the blog and make a comment), editor
(only you can create/edit a post) and admin
(you can create, update and delete a post/comment).
Flight has a package for permissions that we can use. Let's install it with composer:
composer require flightphp/permissions
Now we can add the permissions service to our services.php
file:
// Permissions
$currentRole = $app->session()->getOrDefault('role', 'guest');
$app->register('permission', \flight\Permission::class, [ $currentRole ]);
$permission = $app->permission();
$permission->defineRule('post', function(string $currentRole) {
if($currentRole === 'admin') {
$permissions = ['create', 'read', 'update', 'delete'];
} else if($currentRole === 'editor') {
$permissions = ['create', 'read', 'update'];
} else {
$permissions = ['read'];
}
return $permissions;
});
$permission->defineRule('comment', function(string $currentRole) {
if($currentRole === 'admin') {
$permissions = ['create', 'read', 'update', 'delete'];
} else if($currentRole === 'editor') {
$permissions = ['create', 'read', 'update'];
} else {
$permissions = ['read'];
}
return $permissions;
});
Also let's change some of the LoginController->authenticate()
method to add the user role to the session:
if($postData->password === 'password') {
$this->session()->set('user', $postData->username);
// Sets the current user role
if($postData->username === 'admin') {
$this->session()->set('role', 'admin');
} else if($postData->username === 'editor') {
$this->session()->set('role', 'editor');
} else {
$this->session()->set('role', 'user');
}
$this->session()->commit();
$this->redirect($this->getUrl('blog'));
exit;
}
Note: This is a very simple example of how you can set roles. In a real world application, you would have a database table (or something) that would store the user roles and you would query the database to get the user role. For illustrative purposes, your user role in our blog application will be determined by the username you use to login.
We can also create another function in Latte to help use utilize these permissions. Let's go to the services.php
file and add another function after the route
function:
$Latte->addFunction('permission', function(string $permission, ...$args) use ($app) {
return $app->permission()->has($permission, ...$args);
});
Now we can use the permission
function in our templates. Let's go to the app/views/blog/index.latte
file and add a permission to the create button to check if you really can create a blog post or not:
{if $username && permission('post.create')}
<p><a class="button" href="{route('blog_create')}">Create a new post</a></p>
{/if}
You can now add additional permissions checks for reading, updating, and deleting posts. Go ahead to the login page and login as admin
and password
. Then try it with user
and password
and notice the differences!
Active Record Improvements
The active record class is relatively simple, but it has a few tricks up its sleeve. For instance we can have certain events trigger behaviors for us automatically (like adding an updated_at
timestamp when we update a record). We also can connect records together by defining relationships in the active record class. Let's go to the app/records/PostRecord.php
file and you can paste in the following code:
<?php
declare(strict_types=1);
namespace app\records;
use app\records\CommentRecord;
use Flight;
/**
* ActiveRecord class for the posts table.
* @link https://docs.flightphp.com/awesome-plugins/active-record
*
* @property int $id
* @property string $title
* @property string $content
* @property string $username
* @property string $created_at
* @property string $updated_at
* @method CommentRecord[] comments()
*/
class PostRecord extends \flight\ActiveRecord
{
/**
* @var array $relations Set the relationships for the model
* https://docs.flightphp.com/awesome-plugins/active-record#relationships
*/
protected array $relations = [
'comments' => [self::HAS_MANY, CommentRecord::class, 'post_id'],
];
/**
* Constructor
* @param mixed $databaseConnection The connection to the database
*/
public function __construct($databaseConnection = null)
{
$databaseConnection = $databaseConnection ?? Flight::db();
parent::__construct($databaseConnection, 'posts');
}
public function beforeInsert(): void
{
$this->created_at = gmdate('Y-m-d H:i:s');
$this->updated_at = null;
}
public function beforeUpdate(): void
{
$this->updated_at = gmdate('Y-m-d H:i:s');
}
}
So let's talk about what's different.
Relationships
We've added a protected array $relations
property to the class. This is a way to define relationships between records. In this case, we've defined a comments
relationship. This is a HAS_MANY
relationship, meaning that a post can have many comments. The CommentRecord::class
is the class that represents the comments table, and the post_id
is the foreign key that links the comments to the posts.
What this will allow us to do is in our code we can save a lot of headache but just doing $post->comments()
and it will return all the comments for that post. For example, in the PostController
we can change the show
method to the following:
/**
* Show
*
* @param int $id The ID of the post
* @return void
*/
public function show(int $id): void
{
$PostRecord = new PostRecord($this->db());
$post = $PostRecord->find($id);
// Don't need these as it now will "just work" with the relationship we've defined
// $CommentRecord = new CommentRecord($this->db());
// $post->comments = $CommentRecord->eq('post_id', $post->id)->findAll();
$this->render('posts/show.latte', [ 'page_title' => $post->title, 'post' => $post]);
}
Super cool right? You'll also notice the @method CommentRecord[] comments()
in the docblock. This is a way to tell your IDE that the comments()
method will return an array of CommentRecord
objects giving you a ton of help with autocompletion and type hinting.
Note: This is a very simple example of a relationship. In a real world application, you would have more complex relationships such as
HAS_ONE
,BELONGS_TO
, andMANY_MANY
. You can read more about relationships here.Performance Note: Relationships are great for single records, or for small collections of records. Just remember the fact that everytime you do
->comments()
it will run a query to get the comments. If you have say a query to pull back all the posts for your blog which are say 10 posts, and then put that in a foreach loop, you will have 1 query for your posts, and then 1 query for each of your 10 posts to get the comments. This is called the N+1 problem. It's not a bad problem in a small codebase, but in a large codebase it can be a big problem!
Events
Instead of coding in the date that needs to be assigned to every record, we can actually take care of that automatically through events! Above you'll notice there is now a beforeInsert
and beforeUpdate
method. These are example event methods that are called before the record is inserted or updated. This is a great way to add some default values to your records without having to remember to do it every time. So in the PostController
we can remove the lines that set the created_at
and updated_at
fields like below (cause they'll now be filled in automatically):
$PostRecord = new PostRecord($this->db());
$PostRecord->find($id);
$PostRecord->title = $postData->title;
$PostRecord->content = $postData->content;
// $PostRecord->updated_at = gmdate('Y-m-d H:i:s');
$PostRecord->save();
One other thing that's really helpful with events is that you can automatically hash/encrypt/encode values and decrypt/decode values when you pull out records automatically. For instance in a UserRecord->beforeInsert()
method you could has the password before it's inserted into the database:
public function beforeInsert(): void {
$this->password = password_hash($this->password, PASSWORD_BCRYPT);
}
Auto Create the Database Connection
You'll notice that we've changed the constructor to accept no argument and that if $databaseConnection
is null
it will automatically use the global Flight::db()
connection. This is a nice little feature that allows you to not have to pass in the database connection every time you create a new record and reduces precious keystrokes on your fingies.
public function __construct($databaseConnection = null)
{
$databaseConnection = $databaseConnection ?? Flight::db();
parent::__construct($databaseConnection, 'posts');
}
So now you can just do $PostRecord = new PostRecord();
and it will automatically use the global database connection. But when unit testing you can inject a mock connection to test your active record class.
Reactify/Vueify/Angularify
We totally could have put some JavaScript framework in this project and changed our routes to be ->put()
or ->delete()
instead. This tutorial was just to help you grasp a basic understanding of how the framework works. Go ahead and throw some fancy JS into it in your own blog post!
Conclusion
This was a longer couple of posts, but you made it! Hopefully this illustrated some of the amazing things that Flight can do for you. It's a very lightweight framework that can be extended to do a lot of things. It's not as feature rich as Laravel or Symfony, but it's not trying to be. It's trying to be a simple framework that you can use to understand why you need a framework in the first place.
Do yourself a favor and smash that subscribe button and pat yourself on the back cause you're worth it! You work hard at reading long blog posts about creating blog....posts. :D Leave a comment with any questions or catch us in our chat room
Top comments (1)
Great writeup! There are a lot of gems in these articles. Flight is a great framework, this article highlights just how much you can do with it.