DEV Community

Cover image for Building a Simple Blog with Flight - Part 2
n0nag0n
n0nag0n

Posted on

Building a Simple Blog with Flight - Part 2

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);
    }

}

Enter fullscreen mode Exit fullscreen mode

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' ]);
    }
}
Enter fullscreen mode Exit fullscreen mode

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');
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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);
});
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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

});
Enter fullscreen mode Exit fullscreen mode

Now let's create new controllers with runway:

php runway make:controller Login
php runway make:controller Logout
Enter fullscreen mode Exit fullscreen mode

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'));
    }
}
Enter fullscreen mode Exit fullscreen mode

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'));
    }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Now we can add the session service to our services.php file:

// Session Handler
$app->register('session', \Ghostff\Session\Session::class);
Enter fullscreen mode Exit fullscreen mode

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 as session_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}
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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'));
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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);
});
Enter fullscreen mode Exit fullscreen mode

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}
Enter fullscreen mode Exit fullscreen mode

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() ]));
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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???

Database Panel

Here's Latte to help you know the template you're using.

Latte Panel

And here's the session data. Obviously if we had more session data this would be more useful.

Session Panel

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
Enter fullscreen mode Exit fullscreen mode

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;
});
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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);
});
Enter fullscreen mode Exit fullscreen mode

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}
Enter fullscreen mode Exit fullscreen mode

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');
    }
}
Enter fullscreen mode Exit fullscreen mode

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]);
}
Enter fullscreen mode Exit fullscreen mode

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, and MANY_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();
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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');
}
Enter fullscreen mode Exit fullscreen mode

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)

Collapse
 
mrpercival profile image
Lawrence Cooke

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.