view_headline

Mary Knize

New Project - Thinkery, a Laravel Self-Hosted Markdown Microblog

May 26th, 2019

Writing a blog post for my site is a bit of a commitment. There's creating a new markdown file, adding the correct info at the top of the file, compiling Gatsby once it's done, and then pushing the changes to Github. I wanted to add a microblog to my site to make it easier to update frequently, but I didn't want to use Twitter or even Mastodon to do it.

I figured this would be a great way to learn Laravel, and I was right. I was able to get a prototype up and running in a weekend, and tweaked it over the next weekend until I had something that I felt was secure enough to put on a subdomain and use on my site.

Since it's been almost a month since I finished this project I'm going to give a brief overview of some things I learned about Laravel. If you want to learn more or check out the source code I wrote a pretty extensive README on Github at https://github.com/captainpainway/thinkery.

Setting everything up

Since I already had Composer and PHP installed, getting set up to start a Laravel project was pretty easy. PHP does require a few extensions that I didn't have and needed to install separately.

Requirements:

  • PHP >= 7.1.3
  • OpenSSL PHP Extension
  • PDO PHP Extension
  • Mbstring PHP Extension
  • Tokenizer PHP Extension
  • XML PHP Extension
  • Ctype PHP Extension
  • JSON PHP Extension
  • BCMath PHP Extension

I also seemed to need the Zip PHP Extension even though it's not listed in the docs.

Installing Laravel was as easy as composer global require laravel/installer and then setting my $PATH to include $HOME/.config/composer/vendor/bin. Then, I started by new project by running laravel new thinkery.

Since I knew I would be using MySQL for my database, I went ahead and created a new database and updated the .env file at the root of my Laravel directory.

Artisan

A lot of Laravel's complexities are automated away with Artisan. It was very handy for prototyping quickly, but it also makes some very complex stuff happen magically. It's a little scary not to know exactly how Auth works because you simply run a command and it happens, but it's also great to not have to worry about all of those details for simple user authentication.

Because I knew I wanted to have a simple login page for a single user, I ran php artisan make:auth and php artisan migrate to create the user tables in the database.

After originally creating some of my database tables by hand (as I'm used to doing) I realized that php artisan migrate is an amazing tool for not only abstracting away table generation, but also for making the project easy for others to install. Migrate will create all of your tables in an empty database.

Here's what one of my table migrations looks like:

public function up()
{
    Schema::create('messages', function (Blueprint $table) {
        $table->bigIncrements('id');
        $table->string('message', 600);
        $table->bigInteger('user');
        $table->smallInteger('deleted')->default(0);
        $table->timestamps();
    });
}

There's also an Artisan command for creating a migration, php artisan make:migration.

Because I didn't want to have a public registration page on my site, I took that functionality out and added my own custom Artisan commands to create users, change passwords, and delete users on the command line.

/routes/console.php

Artisan::command('user:create', function() {
    $username = $this->ask('What is your username?');
    $email = $this->ask('What is your email?');
    $password = $this->secret('What is your password?');
    DB::table('users')->insert(['username'=>$username,'email'=>$email,'password'=>Hash::make($password)]);
})->describe('Create a new user');

Artisan::command('user:updatePassword', function() {
    $username = $this->ask('What is your username?');
    $email = $this->ask('What is your email?');
    $password = $this->secret('What is your new password?');
    DB::table('users')->where('username', $username)->where('email', $email)->update(['password' => Hash::make($password)]);
})->describe('Update a user password');

Artisan::command('user:delete', function() {
    $username = $this->ask('What is your username?');
    $email = $this->ask('What is your email?');
    $password = $this->secret('What is your password?');
    $verify = $this->ask('Are you sure you want to delete this user? (yes/no)');
    if ($verify == 'yes' || $verify == 'Yes') {
        $pass = DB::table('users')->select('password')->where('username', $username)->where('email', $email)->first();
        if (Hash::check($password, $pass->password)) {
            DB::table('users')->where('username', $username)->where('email', $email)->delete();
            echo "User deleted\n";
        } else {
            echo "User not found\n";
        }
    } else {
        echo "Delete aborted.\n";
    }
})->describe('Delete a user');

With these custom Artisan commands, the microblog administrator can run php artisan user:create to make a new user, run php artisan user:delete to delete a user, or run php artisan user:updatePassword to update a user's password. Also, these custom commands use $this->ask to ask for user input, and $this->secret to ask for a password, making these commands very user-friendly.

Blade templating

I could've used Vue for the front end of my app, but because I was working quickly and Blade was already in use, I decided to stick with Blade. As a templating engine, I found it pretty easy to work with and found its component system easy to understand for such a small app.

Under the /resources/views/layouts directory I have my one main app.blade.php file. This is mostly where the navbar lives in my app, with @yield('content') where the page content is inserted into the template. This file also contains <meta name="csrf-token" content="{{ csrf_token() }}">, which is important for protecting against cross-site request forgery attacks. Regular HTML forms also need an additional @csrf token.

Forms were an interesting challenge, and since I was following some Laravel tutorials I was kind of flip-flopping between using regular HTML forms that I'm used to (with the @csrf token), and Laravel's "form helpers", which have CSRF protection built-in.

Here's an example of how I used Laravel's Blade forms:

{{ Form::open(array('url' => 'posts')) }}
    <div class="form-group">
        {{ Form::textarea('message', '', array('class' => 'form-control', 'id' => 'message-input', 'value' => 'What\'s your message?', 'rows' => 6, 'maxlength' => 500))}}
    </div>
    <div class="form-group row">
        <div class="col-4 text-right">
            <a id="clear-btn" class="btn btn-default">Clear</a>
            {{ Form::submit('Submit', array('class' => 'btn btn-primary'))}}
        </div>
    </div>
{{ Form::close() }}

(Fun fact, while going over this code I realized that I never actually implemented the JavaScript that makes the Clear button work!)

This simple form takes the microblog message and submits it to the /posts endpoint, and also limits the message to 500 characters.

Controllers

Controllers are what makes a CRUD app do what a CRUD app does. This table in the Laravel controller docs helps explain what each of the actions in the controllers do.

Laravel controller actions

To use the above form as an example, this is what the corresponding controller action does once a POST request is sent to /posts:

public function store(Request $request)
{
    $user_id = Auth::id();
    $message = new Message;
    $message->message = Input::get('message');
    $message->user = $user_id;
    $message->save();

    return Redirect::to('posts');
}

The store action simply gets the message from the form input, adds the authenticated user id (because we technically can have more than one user we want to keep posts connected to the currently logged in user), saves the message to the database, and redirects back to the main posts page.

Once we get redirected to the main posts page, the index action will get all of the posts for the user, including our new post, and display them.

public function index()
{
    $user_id = Auth::id();
    $messages = Message::whereUser($user_id)->orderBy('created_at', 'desc')->whereDeleted(0)->get();
    return view('messages.index')->with('messages', $messages);
}

Conclusion

This just scratches the surface of what I've learned while creating this little Laravel app. I'm tempted to add more functionality for the sake of learning more about Laravel's ins and outs, but really, this serves my needs just fine for now.