Dancer2...Tutorial(3)

πŸ“¦
version 2.2.1 Β· 3 versions
, in fedora 44

πŸ’– Dancer2::Manual::Tutorial(3) β€” the girlies' guide to getting you dancing πŸ’–

User Contributed Perl Documentation Β· Dancer2...Tutorial(3)

okay bestie, grab a matcha because we are about to build a whole blog together, and I'm gonna walk you through every single step like we're sitting on my bedroom floor with a shared laptop. no gatekeeping here. πŸ’…


✨ NAME ✨

Dancer2::Manual::Tutorial β€” A step-by-step guide to get you dancing 🩰

🌸 VERSION 🌸

version 2.2.1

πŸ¦‹ Dancer2 Tutorial πŸ¦‹

So here's the vibe: by the end of this tutorial you're gonna have a fully working blog application. Like, an actual one. Built from simple examples and lil demonstrations of the techniques real Dancer2 apps use. No cap.

If you're the kind of person who likes to peek at the finished product first (valid, honestly), you can clone the git repo with all the code used in this tutorial:

git clone https://github.com/PerlDancer/DLBlog.git

πŸŽ€ Introduction β€” the Danceyland Blog πŸŽ€

So Danceyland (yes, that's a theme park, stay with me) wants to keep everybody posted on what's happening at the park, all the time, forever. And they've decided the best way to do that is publishing a blog. Your mission, should you choose to accept it: build them a blog engine using Dancer2 and some other Perl modules. Main character energy fr fr. πŸ‘‘

⭐ What You'll Learn ⭐

By the time you finish this tutorial, you'll know how to:

  • Scaffold a brand new Dancer2 app 🐣
  • Design and construct routes for an application πŸ›£οΈ
  • Work with sessions, templates, and databases πŸ’Ύ
  • Extend your Dancer2 applications with plugins πŸ”Œ
  • Add authentication to Dancer2 applications πŸ”
  • Perform a basic app deployment πŸš€

🌈 Prerequisites 🌈

  • Linux, BSD, or Windows with WSL and a Linux distribution installed
(Strawberry Perl on Windows may work, but is not guaranteed β€” so like, don't come for me if it doesn't πŸ™ˆ)
  • SQLite 3, installed via your operating system's package manager

🧚 Setting Up Your Environment 🧚

πŸ’œ Installing Dancer2

cpan Dancer2

or

cpanm Dancer2

πŸ“ Creating Your First App

dancer2 gen -a DLBlog

Output:

+ DLBlog
+ DLBlog/.dancer
+ DLBlog/config.yml
+ DLBlog/cpanfile
+ DLBlog/Makefile.PL
+ DLBlog/MANIFEST.SKIP
+ DLBlog/t
+ DLBlog/t/002_index_route.t
+ DLBlog/t/001_base.t
+ DLBlog/environments
+ DLBlog/environments/production.yml
+ DLBlog/environments/development.yml
+ DLBlog/bin
+ DLBlog/bin/app.psgi
+ DLBlog/views
+ DLBlog/views/index.tt
+ DLBlog/public
+ DLBlog/public/dispatch.cgi
+ DLBlog/public/404.html
+ DLBlog/public/favicon.ico
+ DLBlog/public/500.html
+ DLBlog/public/dispatch.fcgi
+ DLBlog/lib
+ DLBlog/lib/DLBlog.pm
+ DLBlog/views/layouts
+ DLBlog/views/layouts/main.tt
+ DLBlog/public/images
+ DLBlog/public/images/perldancer.jpg
+ DLBlog/public/images/perldancer-bg.jpg
+ DLBlog/public/javascripts
+ DLBlog/public/javascripts/jquery.js
+ DLBlog/public/css
+ DLBlog/public/css/style.css
+ DLBlog/public/css/error.css

Your new application is ready! To run it:

    cd DLBlog
    plackup bin/app.psgi

To access your application, point your browser to http://localhost:5000

If you need community assistance, the following resources are available:
- Dancer website: https://perldancer.org
- Twitter: https://twitter.com/PerlDancer/
- GitHub: https://github.com/PerlDancer/Dancer2/
- Mailing list: https://lists.perldancer.org/mailman/listinfo/dancer-users
- IRC: irc.perl.org#dancer

Happy Dancing!

Make sure you actually change into your new project directory before you do anything else, ok? Don't be that girl working in the wrong folder:

cd DLBlog

πŸ¦„ Understanding Directory Structure πŸ¦„

Your app directory is now stacked with subdirectories and here's what each one is giving:

  • bin β€” Contains the PSGI file that starts your application. Can be used for other utilities and scripts for your application.
  • environments β€” Configuration information. One file per environment (such as "development" and "production").
  • lib β€” Your core application code. This is where you'll put your models, controllers, and other code.
  • public β€” Contains static files such as images, CSS, and JavaScript. Also contains instance scripts for CGI and FastCGI.
  • t β€” Tests for your application.
  • views β€” Contains templates and layouts.

πŸ”₯ Using Plackup for Development πŸ”₯

plackup -r bin/app.psgi

This autorestarts your application whenever changes are made to your code. It's literally ideal during development, but bestie β€” do not use this in a production setting. That's a no. 🚫


πŸ’… Configuring Our Application πŸ’…

By default, new Dancer2 applications use Dancer2::Template::Tiny. And like, that works well for some types of apps, but it's just not well suited to applications like the Danceyland Blog. So instead, we're gonna configure this app to use Template::Toolkit, which is a total mainstay of Perl templating. Iconic.

To enable Template Toolkit, you'll need to edit your config.yml:

# template engine
# tiny: default basic template engine
# template_toolkit: TT

#template: "tiny"

template: "template_toolkit"
engines:
  template:
    template_toolkit:
      # Note: start_tag and end_tag are regexes
      start_tag: '<%'
      end_tag:   '%>'

YAML files, like your application config, support the same comment characters as Perl. Dancer2::Template::Tiny gets disabled by commenting that line out in your config file, and the default TT config is used by uncommenting the "template" and "engines" configuration. Easy.

So this is telling Dancer2 to:

  • Load the Template Toolkit template engine
  • Specify Template Toolkit as the template engine to use
  • Passes configuration to the TT engine ("start_tag, end_tag")

We're gonna be visiting this file regularly throughout the tutorial, so don't forget where it lives. πŸ’–

config.yml is the right place to set configuration common to all environments (dev, production, etc.), such as the template engine to use. Later, we'll see how to use different configuration parameters in different environments.


🌟 CRUD Routes 🌟

CRUD is an acronym for Create, Read, Update, and Delete. These are the basic operations for any database-driven application β€” such as the Danceyland Blog. The fundamentals, the blueprint, the moment. 🧚

🌸 What Routes are Needed? 🌸

Blogs contain one or more entries, which we need to be able to Create, Read, Update, and Delete. So we'll need the following routes:

  • Create a blog entry. This corresponds to the HTTP "POST" method. It also requires a form to input the data, which is displayed using the HTTP "GET" method.
  • Read (i.e., display) a blog entry. This corresponds to the HTTP "GET" method.
  • Update a blog entry. If we were building an API or single-page application, this would correspond to the HTTP "PUT" or "PATCH" methods. In a traditional CRUD-based web application, this will use the "POST" method.

There is a UI component to this as well, which will use the HTTP "GET" method.

  • Delete a blog entry. If we were building an API or single-page application, this would correspond to the HTTP "DELETE" method. However, for simplicity, we'll use the HTTP "GET" method to prompt the user for confirmation, and a "POST" route to actually perform the deletion.

We'll also need a route to see the list of all blog entries. This will use the HTTP "GET" method.

✨ Add an Entry (Create) ✨

This requires two routes: one to display the create form, and another to process the input and create the entry.

To display the entry form:

get '/create' => sub {
    return "Show the entry form";
};

To process the input and create the blog entry:

post '/create' => sub {
    return "Creates the new blog entry";
};

πŸ’œ Display an Entry (Read) πŸ’œ

This route is similar to the one needed to display the creation form:

get '/entry/:id' => sub {
    my $id = route_parameters->get('id');
    return "Showing entry ID $id";
};

The route declaration contains a parameter this time: the id of the blog entry we wish to view. The "route_parameters" keyword is used to fetch the ID passed to our application. 🫢

πŸ¦‹ Update an Entry (Update) πŸ¦‹

As with adding an entry, this requires two routes: one to display the entry form, and another to process the changes:

get '/update/:id' => sub {
    my $id = route_parameters->get('id');
    return "Show the update form for entry id $id";
};

post '/update/:id' => sub {
    my $id = route_parameters->get('id');
    return "Updates the specified blog entry";
};

Again, the entry ID is provided to our Dancer2 application as a route parameter.

πŸŽ€ Delete an Entry (Delete) πŸŽ€

This also needs two routes: one to confirm the user wants to delete the blog entry, and another to actually delete it:

get '/delete/:id' => sub {
    my $id = route_parameters->get('id');
    return "Show the delete confirmation for entry id $id";
};

post '/delete/:id' => sub {
    my $id = route_parameters->get('id');
    return "Deletes the specified blog entry";
};

⭐ Displaying the Entry List ⭐

Finally, we need a route to display the list of all blog entries:

get '/' => sub {
    return "Show the list of blog entries";
};

🌈 Putting it all Together 🌈

So far, lib/DLBlog.pm should look like this:

package DLBlog;
use Dancer2;

get '/' => sub {
    return "Show the list of blog entries";
};

get '/entry/:id' => sub {
    my $id = route_parameters->get('id');
    return "Showing entry ID $id";
};

get '/create' => sub {
    return "Show the entry form";
};

post '/create' => sub {
    return "Creates the new blog entry";
};

get '/update/:id' => sub {
    my $id = route_parameters->get('id');
    return "Show the update form for entry id $id";
};

post '/update/:id' => sub {
    my $id = route_parameters->get('id');
    return "Updates the specified blog entry";
};

get '/delete/:id' => sub {
    my $id = route_parameters->get('id');
    return "Show the delete confirmation for entry id $id";
};

post '/delete/:id' => sub {
    my $id = route_parameters->get('id');
    return "Deletes the specified blog entry";
};

true;

Run your application! πŸƒβ€β™€οΈ

Let's see how it works. From the shell:

plackup -r bin/app.psgi

Then open your browser and test your GET routes (we have nothing to post yet!):


🌸 Setting Up Views 🌸

🧚 Layout 🧚

Let's replace views/layouts/main.tt with simple HTML5 boilerplate:

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="<% settings.charset %>">
    <meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=yes">
    <title><% title %></title>
    <link rel="stylesheet" href="<% request.uri_base %>/css/style.css">
  </head>
  <body>
    <% content %>
    <div id="footer">
    Powered by <a href="https://perldancer.org/">Dancer2</a> <% dancer_version %>
    </div>
  </body>
</html>

okay so this is our barebones little starter kit that we're gonna build the whole blog on top of 🫢 It handles character encoding properly, sets the application title, pulls in a stylesheet, and drops a footer down at the bottom showing which version of Dancer2 is running the whole show. iconic ✨


πŸŽ€ Templates πŸŽ€

See that special <% content %> variable up there? That's literally where every template our routes render gets slotted in. So instead of returning plain strings from the routes we wrote, let's make actual templates holding the UI we need to mess with the blog, plus the business logic that decides when to show them. vibe check passed πŸ’…

πŸ“ The Index Route ("/")

Swap out the existing views/index.tt for this:

<div id="entries">
<% IF entries.size %>
    <dl>
    <% FOREACH entry IN entries %>
        <dt><a href="/entry/<% entry.id %>"><% entry.title | html_entity %></a>
            (created at <% entry.created_at | html_entity %>)</dt>
        <dd><% entry.summary | html_entity %></dd>
    <% END %>
    </dl>
<% ELSE %>
    <p>No entries found.</p>
<% END %>
</div>

This list is gonna get populated later, bestie β€” specifically when we read entries out of the database further down this tutorial. πŸ¦‹

And what's the deal with all these html_entity things?? Okay so the | is Template Toolkit syntax for a filter, and html_entity is the filter we're using here. This particular one properly escapes characters like ampersands when it displays them as HTML. safety first, always πŸ›‘οΈ

To actually use this template in our app, we plug it into our route with the template keyword:

get '/' => sub {
    my @entries; # We'll populate this later
    template 'index', { entries => \@entries };
};

πŸ¦‹ The View Entry Route "/entry/:id"

New template time β€” views/entry.tt β€” and we're loading it with everything we think we'll need to show off a blog entry:

<% IF entry %>
<div id="sidebar">
    <h1>Posted at</h1>
    <p><% entry.created_at | html_entity %></p>
    <h1>Summary</h1>
    <p><% entry.summary | html_entity %></p>
</div>
<div id="content">
    <h2><% entry.title | html_entity %></h2>
    <p><% entry.content | html_entity %></p>
<% ELSE %>
    <p>Invalid entry.</p>
<% END %>
</div>

entry here is a hashref holding the contents of our blog post. If a valid entry ID doesn't show up, we tell the user what's up instead. Otherwise they get a two-column layout: metadata chilling in the sidebar, blog content taking the main stage. <sparkle>main character energy ✨</sparkle>

fair warning: this is gonna be ugly as written. We add styling at the end to make it look nicer, promise πŸ’–

Now let's tweak our viewing route to use the template:

get '/entry/:id' => sub {
    my $id = route_parameters->get('id');
    my $entry; # Populated from the database later
    template 'entry', { entry => $entry };
};

πŸ’œ The Create Entry Routes "/create"

Just a lil simple form to make a new blog entry. Make views/create.tt with this inside:

<div id="create">
    <form method="post" action="<% request.uri_for('/create') %>">
        <label for="title">Title</label>
        <input type="text" name="title" id="title"><br>
        <label for="summary">Summary</label>
        <input type="text" name="summary" id="summary"><br>
        <label for="content">Content</label>
        <textarea name="content" id="content" cols="50" rows="10"></textarea><br>
        <input type="submit">
    </form>
</div>

Using uri_for when specifying the POST URL lets Dancer2 cook up a proper URL no matter where your app happens to be running from. portable queen πŸ‘‘

Drop it into the create route:

get '/create' => sub {
    template 'create';
};

There's no template when POSTing to /create. There are two possible outcomes when creating a blog entry:

  1. Creation was successful, so we redirect somewhere sensible
  2. Creation failed, so we redisplay the filled in form and show an error

We'll show how to do both of these in the implementation section later. For now:

post '/create' => sub {
    my $new_id = 1;
    redirect uri_for "/entry/$new_id"; # redirect does not need a return
};

πŸ’… The Update Entry Routes ("/update/:id")

Showing the UI for updating a blog entry is basically the same thing we did in create above, except we need to display the existing values of an entry inside the form. Like… we're not gonna make the user re-enter everything when updating, are we?? Absolutely not, bestie. Since we have no values to show right now, we implement that latter part in the implementation section below.

For now, let's just return the existing create form:

get '/update/:id' => sub {
    my $id = route_parameters->get('id');
    template 'create';
};

We don't need a special template for showing the results of an update β€” we just need to show the resulting entry. And wait… don't we already have a route that does that? Why would we do that work again here? We shouldn't! reuse is the vibe 🌈

Dancer2 will let you change the flow of your application using the forward or redirect keywords. But which one do we pick? πŸ€”

  • forward is an internal redirect This lets you perform the functionality contained in another route, but it's done internally, which means the URL shown to the user in their browser's address bar looks like the one they requested. In the case of our update, the browser bar will show http://localhost:5000/update/1, but what they'd actually be seeing is the output from http://localhost:5000/entry/1. This is not desirable, because it's confusing for a user to look at.
  • redirect is an external redirect External redirects send the user to a different URL by sending a redirect response from Dancer2. Even though the user is executing a POST to http://localhost:5000/update/1, upon completion, their browser bar will show http://localhost:5000/entry/1. This is the better option, because when an update completes successfully, the URL will match what the user sees in their browser. slay ✨

To do this, let's use the redirect keyword:

post '/update/:id' => sub {
    my $id = route_parameters->get('id');
    redirect uri_for "/entry/$id"; # redirect does not need a return
};

We will show how to update an existing entry later in the tutorial.

🌸 The Delete Routes

It wouldn't be kind to the user to just delete an entry without giving them the option to change their mind β€” and that's exactly what the GET route for deletion is for. consent, but for data πŸ’œ

Let's give the user a simple confirmation form. Make views/delete.tt:

<div id="delete">
    <form method="post" action="<% request.uri_for('/delete/' _ id) %>">
        <p>Delete entry <% id %>. Are you sure?</p>
        <p>
            <input type="radio" id="yes" name="delete_it" value="yes">
            <label for="yes">Yes</label>
        </p>
        <p>
            <input type="radio" id="no" name="delete_it" value="no">
            <label for="no">No</label>
        </p>
        <input type="submit">
    </form>
</div>

Our GET route for delete needs to change:

get '/delete/:id' => sub {
    my $id = route_parameters->get('id');
    template 'delete', { id => $id };
};

Same energy as create and update: there's no need to make another template just to show the results of a delete. We'll simply send the user back to the list of blog entries:

post '/delete/:id' => sub {
    my $id = route_parameters->get('id');

    # Always default to not destroying data
    my $delete_it = body_parameters->get('delete_it') // 0;

    if( $delete_it ) {
        # Do the deletion here
        redirect uri_for "/";
    } else {
        # Display our entry again
        redirect uri_for "/entry/$id";
    }
};

Notice the addition of body_parameters? That's how we grab the value of the radio button the user picked. Super important for deciding whether the user actually wants to delete or not. 🫢


⭐ Reviewing our Application Code ⭐

lib/DLBlog.pm should now look like this:

package DLBlog;
use Dancer2;

get '/' => sub {
    my @entries; # We'll populate this later
    template 'index', { entries => \@entries };
};

get '/entry/:id' => sub {
    my $id = route_parameters->get('id');
    my $entry; # Populated from the database later
    template 'entry', { entry => $entry };
};

get '/create' => sub {
    template 'create';
};

post '/create' => sub {
    my $new_id = 1;
    redirect uri_for "/entry/$new_id"; # redirect does not need a return
};

get '/update/:id' => sub {
    my $id = route_parameters->get('id');
    template 'create';
};

post '/update/:id' => sub {
    my $id = route_parameters->get('id');
    redirect uri_for "/entry/$id";
};

get '/delete/:id' => sub {
    my $id = route_parameters->get('id');
    template 'delete', { id => $id };
};

post '/delete/:id' => sub {
    my $id = route_parameters->get('id');

    # Always default to not destroying data
    my $delete_it = body_parameters->get('delete_it') // 0;

    if( $delete_it ) {
        # Do the deletion here
        redirect uri_for "/";
    } else {
        # Display our entry again
        redirect uri_for "/entry/$id";
    }
};

true;

look at us, we have a whole app πŸ₯Ή


🌈 Adding Utility to our Views 🌈

Okay so all of this is well and good, but it's not the most functional for our users. A basic menu of available options would help users understand what actions can be performed in the Danceyland Blog. ngl, wayfinding is everything πŸ’…

🧚 Adding a Menu to our Layout

Okay so picture this: every single page of our app needs that same lil menu, and copying it into the list, create, update, and delete templates would be a crime honestly. So we're putting it in the layout once. Iconic behavior, zero duplication. πŸ’…

Our menu is giving:

<div id="menu">
    <a href="<% request.uri_for('/') %>">List All Entries</a>&nbsp;|&nbsp;
    <a href="<% request.uri_for('/create') %>">Create New Entry</a>
</div>

Now bestie, we drop it at the top of our layout. The whole thing ends up looking like:

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="<% settings.charset %>">
    <meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=yes">
    <title><% title %></title>
    <link rel="stylesheet" href="<% request.uri_base %>/css/style.css">
  </head>
  <body>
    <div id="menu">
        <a href="<% request.uri_for('/') %>">List All Entries</a>&nbsp;|&nbsp;
        <a href="<% request.uri_for('/create') %>">Create New Entry</a>
    </div>
    <% content %>
    <div id="footer">
    Powered by <a href="https://perldancer.org/">Dancer2</a> <% dancer_version %>
    </div>
  </body>
</html>

Refresh your browser and boom β€” menu at the top of your page. Slay. ✨


🌈 The Danceyland Database 🌈

Okay so we need somewhere to persist our blog entries, and relational databases are literally built for this. We're using SQLite for this tutorial, but fr fr you can use any database supported by Dancer2::Plugin::Database and DBI. Your choice, your vibe. πŸ¦‹

SQLite <https://sqlite.org> is a lightweight, single file database β€” it makes adding relational database functionality to a low-concurrency web application super easy. Lowkey perfect for us right now.

πŸŽ€ Setting Up the Database πŸŽ€

At minimum we need a table to hold our blog entries. First, make a new directory for your database and SQL files:

$ mkdir db

Then create a new file, db/entries.sql, with this inside:

CREATE TABLE entries (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    title TEXT NOT NULL,
    summary TEXT NOT NULL,
    content TEXT NOT NULL,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

Later on we'll add an additional table for users. One thing at a time, bestie.

Now let's actually create our blog database with that table. From your project directory:

$ sqlite3 db/dlblog.db < db/entries.sql

And that's it, we have a database. Periodt. πŸ’–

🧚 Using Dancer2::Plugin::DBIx::Class 🧚

Dancer2::Plugin::DBIx::Class is a plugin that integrates the DBIx::Class Object Relational Mapper (ORM) and Dancer2. It maps tables, rows, and columns into classes, objects, and methods in Perl. DBIx::Class (DBIC for short, because we're all about the abbreviations) makes working with databases in your Perl apps genuinely convenient and cuts down on a ton of manual SQL writing.

Real talk though: DBIC is also a large and complex beast, and it can take some time to become proficient with it. This tutorial merely scrapes the surface of what you can do with it; for more information, check out DBIx::Class::Manual::DocMap. πŸ‘‘

You'll also need to install two other dependencies β€” DBD::SQLite (the SQLite database driver), DBIx::Class::Schema::Loader (for automatically generating database classes from tables), and DateTime::Format::SQLite (to interact with dates in DBIC objects).

Install them using cpan or cpanm:

cpanm DBD::SQLite Dancer2::Plugin::DBIx::Class \
    DBIx::Class::Schema::Loader DateTime::Format::SQLite

Add these to your cpanfile:

requires "DBD::SQLite";
requires "Dancer2::Plugin::DBIx::Class";
requires "DBIx::Class::Schema::Loader";
requires "DateTime::Format::SQLite";

And then add it to the top of lib/DLBlog.pm right after use Dancer2;:

use Dancer2::Plugin::DBIx::Class;

Now we gotta add configuration telling our plugin where to find the SQLite database. For this project it's totally sufficient to put the database config in config.yml. In a production application you'd have different database credentials in your development and staging environments than in your production environment (we'd hope you would anyhow!). And that is exactly where environment config files come in handy. 🌸

By default, Dancer2 runs your application in the development environment. So we'll add plugin configuration accordingly. Add this to your environments/development.yml file:

plugins:
  DBIx::Class:
    default:
      dsn: "dbi:SQLite:dbname=db/dlblog.db"
      schema_class: "DLBlog::Schema"
      dbi_params:
        RaiseError: 1
        AutoCommit: 1

Not me pointing out that we only provided DBIx::Class for the plugin name β€” Dancer2 automatically infers the Dancer2::Plugin:: prefix. Magic. ✨

Since SQLite databases are just a local file, we don't need to provide login credentials for the database. The two settings in the dbi_params section tell DBIx::Class to raise an error automatically to our code (should one occur), and to automatically manage transactions for us (so we don't have to). Obsessed with the convenience honestly. 🫢

πŸ’œ Generating Schema Classes πŸ’œ

DBIx::Class relies on class definitions to map database tables to Perl constructs. Thankfully, DBIx::Class::Schema::Loader can do much of this work for us. Thank you loader, you're the main character. πŸ™ˆ

To generate the schema object, and a class that represents the entries table, run the following from your shell:

dbicdump -o dump_directory=./lib \
    -o components='["InflateColumn::DateTime"]' \
    DLBlog::Schema dbi:SQLite:db/dlblog.db '{ quote_char => "\"" }'

This creates two new files in your application:

  • lib/DLBlog/Schema.pm This is a class that represents all database schema.
  • lib/DLBlog/Schema/Result/Entry.pm This is a class representing a single row in the entries table.

Two files, zero manual class-writing. We ate. πŸ“

πŸ’– Implementing the Danceyland Blog πŸ’–

okay bestie, first things first: we have to create an entry and save it to the database. literally every other route depends on us having at least one entry (in some form or another). no entry, no blog, no slay. periodt.


πŸ” Performing Queries πŸ”

Dancer2::Plugin::DBIx::Class is the moment β€” it lets us run SQL queries against a database without, like, hand-writing SQL every single time. it hands us methods to interact with our data: find, search, create, and update. these are the icons, honestly. they do all the work of writing and executing the SQL in the background, so our code stays way easier to maintain. ate. πŸ’…

so let's use a convenience method to create a new blog entry. here's the form we made for entering a blog post:

<div id="create">
    <form method="post" action="<% request.uri_for('/create') %>">
        <label for="title">Title</label>
        <input type="text" name="title" id="title"><br>
        <label for="summary">Summary</label>
        <input type="text" name="summary" id="summary"><br>
        <label for="content">Content</label>
        <textarea name="content" id="content" cols="50" rows="10"></textarea><br>
        <button type="submit">Save Entry</button>
    </form>
</div>

and we can take the values submitted through this form and turn them into an actual row in the database, like this:

post '/create' => sub {
    my $params = body_parameters();

    my $entry = do {
        try {
            resultset('Entry')->create( $params->as_hashref );
        }
        catch( $e ) {
            error "Database error: $e";
            var error_message => 'A database error occurred; your entry could not be created',
            forward '/create', {}, { method => 'GET' };
        }
    };
    redirect uri_for "/entry/" . $entry->id; # redirect does not need a return
};

form fields get sent to Dancer2 as body parameters, so we need the body_parameters keyword to grab them:

my $params = body_parameters();

that gives us back all the body parameters as one single hashref, where every form field name is a key and the value is whatever the user typed into that field. cute, right? πŸŽ€

real talk though: in a production environment you'd want to sanitize this data before you even think about touching a database operation. sanitizing means making sure the data only contains the values you'd actually expect to receive. if it's not what you expected, you can either strip the extra cruft out (that's the sanitizing part) or ask the user to fix their entry. 🌸

database operations can fail β€” that's just life β€” so we should make an attempt to trap any errors. try/catch is honestly made for this kind of error checking. newer versions of Perl have a built-in try keyword, but older versions don't. to protect against that, let's install Feature::Compat::Try β€” it uses the built-in try if your Perl has it, and otherwise provides a backported implementation. to install it, run cpanm:

cpanm Feature::Compat::Try

and add it to your cpanfile:

requires "Feature::Compat::Try";

then make sure you include it at the top of your application:

use Feature::Compat::Try;

the code inside the try block gets executed, and if it fails, it catches the error and runs the code in that block.

try {
    resultset('Entry')->create( $params->as_hashref );
}

this uses the quick_insert method of our Database plugin, and passes the values from the form through to create a row in the entries table.

if a database error happens, we have to handle it:

catch( $e ) {
    error "Database error: $e";
    var error_message => 'A database error occurred; your entry could not be created',
    forward '/create', {}, { method => 'GET' };
}

the first line makes an error log message containing the actual database error β€” this is gonna be gold when you're debugging your app or troubleshooting something a user hit. then we stash a message in a variable so it can be displayed on the create page once that page gets redisplayed. for the sake of brevity, we populate the message with a really basic error message. in a production application you'd want to hand the user something more descriptive to help them resolve their own problem, if possible. 🫢

and why not just pass the database error straight to the user? because that hands a potential attacker free information about your database and your application. do not do that. πŸ’€

finally, we send the user back to the entry form:

forward uri_for '/create', {}, { method => 'GET' };

by default, forward invokes a route using the same HTTP verb as the route it's executing from. you can change the verb by passing a third hashref containing the method key. the second (empty) hashref holds an optional list of parameters to pass along to the forwarded route.

if the insert succeeds, create returns an object representing the newly created database row, and we assign it to the $entry variable. we can run extra database operations against that row by calling methods on $entry. and as a little convenience for the user, we should take them somewhere they can actually see their brand new entry:

debug 'Created entry ' . $entry->id . ' for "' . $entry->title . '"';
redirect uri_for "/entry/" . $entry->id; # redirect does not need a return

the first line logs a message showing the post was successfully created, then the last line redirects the user to the entry display page. main character behaviour honestly. πŸ‘‘


πŸ” Redisplaying the Create Form πŸ”

when there's an error, it's good practice to redisplay the original form with the previous values still filled in. we could add code in our POST route to redisplay the form... or we could just reuse the code we already wrote to display the form. yeah, we're going with the second one. recycling is iconic. ♻️

Dancer2's var keyword lets you create variables you can use elsewhere in your application, including in your templates. we'll use these to keep track of what the user already entered, and to show the user a message if any required parameters are missing.

to save any entered parameters into vars, add this line right after the call to body_parameters:

var $_ => $params->{ $_ } foreach qw< title summary content >;

now let's check whether any of those were left out when the user submitted the create form:

my @missing = grep { $params->{$_} eq '' } qw< title summary content >;
if( @missing ) {
    var missing => join ",", @missing;
    warning "Missing parameters: " . var 'missing';
    forward '/create', {}, { method => 'GET' };
}

if any of title, summary, or content are missing, we build up a message containing the list of missing parameters. then we set a variable, missing, with the message to display. and finally we internally redirect (via the forward keyword) back to the GET route that displays our create form. no cap, that's the whole trick. ✨

your new post '/create' route should now look like this:

post '/create' => sub {
    my $params = body_parameters();
    var $_ => $params->{ $_ } foreach qw< title summary content >;

    my @missing = grep { $params->{$_} eq '' } qw< title summary content >;
    if( @missing ) {
        var missing => join ",", @missing;
        warning "Missing parameters: " . var 'missing';
        forward '/create', {}, { method => 'GET' };
    }

    my $entry = do {
        try {
            resultset('Entry')->create( $params->as_hashref );
        }
        catch( $e ) {
            error "Database error: $e";
            var error_message => 'A database error occurred; your entry could not be created',
            forward '/create', {}, { method => 'GET' };
        }
    };

    debug 'Created entry ' . $entry->id . ' for "' . $entry->title . '"';
    redirect uri_for "/entry/" . $entry->id; # redirect does not need a return
};

these changes to our post '/create' route make for some pretty succinct code that's easy to follow β€” but it might not be the best for a production application. better error handling could be added, there's no retry logic for failed database connections, data validation is lacking, etc. all of those features can be added to the blog later as additional exercises for the developer. homework era. πŸ“š


πŸŽ€ Updating the create form to show previous values πŸŽ€

we need to adjust the create form so it shows the values we stashed as variables with the var keyword:

<div id="create">
    <form method="post" action="<% request.uri_for('/create') %>">
        <label for="title">Title</label>
        <input type="text" name="title" id="title" value="<% vars.title %>"><br>
        <label for="summary">Summary</label>
        <input type="text" name="summary" id="summary" value="<% vars.summary %>"><br>
        <label for="content">Content</label>
        <textarea name="content" id="content" cols="50" rows="10"><% vars.content %></textarea><br>
        <button type="submit">Save Entry</button>
    </form>
</div>

variables stashed in your Dancer2 application are available through the vars hashref, like <% vars.title %>. so when /create displays this form, any stashed variable values get filled right back into their appropriate form element. it's giving memory. 🧠✨


πŸ’¬ Displaying messages πŸ’¬

in our application we created a message to display a list of any missing required fields, and we also created an error message for when our database operation fails. now we need to create a place in the layout to actually display them.

below our menu, but above our content, add the following:

<% IF vars.missing %>
<div id="missing">
    <b>Missing parameters: <% vars.missing | html_entity %></b>
</div>
<% END %>
<% IF error_message %>
<div id="error">
    <b>Error: <% vars.error_message | html_entity %></b>
</div>
<% END %>

this creates two message divs: one that displays missing values, and another that displays errors. keeping them separate means we can style each one appropriately later. aesthetic. 🌈

see those IF/END blocks? that content is optional β€” meaning if there are no missing fields and no error messages, this markup won't get added to the rendered HTML page at all. so clean. πŸ¦‹

the layout should now look like:

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="<% settings.charset %>">
    <meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=yes">
    <title><% title %></title>
    <link rel="stylesheet" href="<% request.uri_base %>/css/style.css">
  </head>
  <body>
    <div id="menu">
        <a href="<% request.uri_for('/') %>">List All Entries</a>&nbsp;|&nbsp;
        <a href="<% request.uri_for('/create') %>">Create New Entry</a>
    </div>
    <% IF vars.missing %>
    <div id="missing">
        <b>Missing parameters: <% vars.missing | html_entity %></b>
    </div>
    <% END %>
    <% IF error_message %>
    <div id="error">
        <b>Error: <% vars.error_message | html_entity %></b>
    </div>
    <% END %>
    <% content %>
    <div id="footer">
    Powered by <a href="https://perldancer.org/">Dancer2</a> <% dancer_version %>
    </div>
  </body>
</html>

🌈 Displaying Blog Data 🌈

okay so showing off one single blog entry? genuinely the easiest little thing, bestie. the quick_select method of Dancer2::Plugin::Database hands you back a database row as a hashref, and you just... pass that bad boy straight to a template as a parameter. that's it. that's the whole vibe:

get '/entry/:id[Int]' => sub {
    my $id = route_parameters->get('id');
    my $entry = resultset( 'Entry' )->find( $id );
    template 'entry', { entry => $entry };
};

you might clock that the route declaration changed πŸ‘€ and yeah, because Dancer2 will let you decorate a route parameter with a Type::Tiny datatype. so if the parameter someone throws at you doesn't match the type Dancer2 expects, you get an HTTP 404 status back. no negotiating. and the glow-up? any call to route_parameters is then guaranteed to hand you a value of the desired type. no more vibe-checking your inputs yourself, she's got you 🫢

we're using the find() method of DBIx::Class::ResultSet to grab a single row and turn it into an object, which we'll call $entry like it's her name. and should find not succeed? our template will say so, loudly and cutely:

<% IF entry %>
<div id="sidebar">
    <h1>Posted at</h1>
    <p><% entry.created_at | html_entity %></p>
    <h1>Summary</h1>
    <p><% entry.summary | html_entity %></p>
</div>
<div id="content">
    <h2><% entry.title | html_entity %></h2>
    <p><% entry.content | html_entity %></p>
<% ELSE %>
    <p>Invalid entry.</p>
<% END %>
</div>

by passing the resultset object directly into the template, you can call methods right on that object to display different columns of info, like title and content. and if there's no valid entry? the ELSE section of the template shows up instead of the blog post content. iconic behaviour honestly ✨


πŸ’… Updating a blog entry πŸ’…

to update a blog entry we need a form holding the values that were already entered for a given post. and like... didn't we literally already do that when we were redisplaying the create form because values were missing? yes. yes we did. so what if we could reuse that exact form for editing an existing entry? with a little bit of work, we absolutely can. obsessed.

rename create.tt to be create_update.tt, then swap the contents for this:

<div id="create_update">
    <form method="post" action="<% post_to %>">
        <label for="title">title</label>
        <input type="text" name="title" id="title" value="<% vars.title %>"><br>
        <label for="summary">summary</label>
        <input type="text" name="summary" id="summary" value="<% vars.summary %>"><br>
        <label for="content">content</label>
        <textarea name="content" id="content" cols="50" rows="10"><% vars.content %></textarea><br>
        <button type="submit">Save Entry</button>
    </form>
</div>

the following minor changes have been made, and yes they're small but they matter:

  • the div id was changed to reflect the form's new purpose
  • the form action was changed to the template variable post_to

the form action is gonna be different depending on whether we're creating a new blog entry or updating an existing one. that's the whole point of post_to bestie πŸŽ€

so now we need a route to display the form in a way that's suitable for updating a blog entry:

get '/update/:id[Int]' => sub {
    my $id = route_parameters->get('id');
    my $entry = resultset( 'Entry' )->find( $id );
    var $_ => $entry->$_ foreach qw< title summary content >;
    template 'create_update', { post_to => uri_for "/update/$id" };
};

same as our display-a-blog-entry route, we include the type of parameter (Int) we expect to receive. and Dancer2 will throw a 404 not found error if that parameter is anything other than an integer. periodt. we also try to fetch the row from the entries table identified with the id passed to the app.

once an entry has been retrieved, we populate the exact same list of variables that we did to redisplay the form when required values were missing earlier. then finally, we build the correct post_to URL for updating a blog entry and pass it along to the template. chef's kiss πŸ‘‘

and just like with create, we need a POST route to actually process the blog update:

post '/update/:id[Int]' => sub {
    my $id = route_parameters->get('id');
    my $entry = resultset( 'Entry' )->find( $id );
    if( !$entry ) {
        status 'not_found';
        return "Attempt to update non-existent entry $id";
    }

    my $params = body_parameters();
    var $_ => $params->{ $_ } foreach qw< title summary content >;
    my @missing = grep { $params->{$_} eq '' } qw< title summary content >;
    if( @missing ) {
        var missing => join ",", @missing;
        warning "Missing parameters: " . var 'missing';
        forward "/update/$id", {}, { method => 'GET' };
    }

    try {
        $entry->update( $params->as_hashref );
    }
    catch( $e ) {
        error "Database error: $e";
        var error_message => 'A database error occurred; your entry could not be updated',
        forward "/update/$id", {}, { method => 'GET' };
    }
    redirect uri_for "/entry/" . $entry->id; # redirect does not need a return
};

there's an extra check in here that wasn't needed for create: a check to make sure the blog post we're updating actually exists. if it doesn't, the correct response is to fire off a 404 not found at the user. no cap:

if( !$entry ) {
    status 'not_found';
    return "Attempt to update non-existent entry $id";
}

the status keyword sets the proper response code in the response header, and the message in the return sends extra info back to the user. so they know exactly what went wrong πŸ’€

once the proper blog entry has been loaded, the exact same logic needed to create an entry applies to updating one. literally the same. we love reuse.

Updating the create form's post_to

just call uri_for in our route, then hand the resulting value over to create_update.tt:

get '/create' => sub {
    # Vars are passed to templates automatically
    template 'create_update', { post_to => uri_for '/create' };
};

πŸ¦‹ Deleting a Blog Entry πŸ¦‹

a simple GET route is needed to show info about the route to be deleted:

get '/delete/:id[Int]' => sub {
    my $id = route_parameters->get('id');
    my $entry = resultset( 'Entry' )->find( $id );
    template 'delete', { entry => $entry };
};

we require a single route parameter: an integer that represents the ID of the blog post we wish to delete. then we try to load that entry from the database with the find() method of DBIx::Class::ResultSet, and dump it into our earlier delete template. easy.

speaking of which β€” let's modify our template to use methods on our resultset object:

<% IF entry %>
<div id="delete">
    <form method="post" action="<% request.uri_for('/delete/' _ entry.id) %>">
        <p>Delete entry <% entry.id %>. Are you sure?</p>
        <div>
            <input type="radio" id="yes" name="delete_it" value="yes">
            <label for="yes">Yes</label>
        </div>
        <div class="form-check">
            <input type="radio" id="no" name="delete_it" value="no">
            <label for="no">No</label>
        </div><br>
        <button type="submit">Delete Entry</button>
    </form>
<% ELSE %>
    <p>Invalid entry.</p>
<% END %>
</div>

anywhere we'd been using a parameter earlier now calls a corresponding method on the entry object. and we've also added a check to display a message if an invalid entry ID was provided. safety first, always 🫢

now let's write the code to actually perform the delete:

post '/delete/:id[Int]' sub {
    my $id = route_parameters->get('id');
    my $entry = resultset( 'Entry' )->find( $id );
    if( !$entry ) {
        status 'not_found';
        return "Attempt to delete non-existent entry $id";
    }

    # Always default to not destroying data
    my $delete_it = body_parameters->get('delete_it') // 0;

    if( $delete_it ) {
        $entry->delete;
        redirect uri_for "/";
    } else {
        # Display our entry again
        redirect uri_for "/entry/$id";
    }
};

this uses all of the same techniques and logic we've built previously, like:

  • a route definition that checks the datatype of the ID
  • a check to ensure a blog post with that ID exists
  • the code to safely perform a delete
once a resultset object has been instantiated, calling the delete method removes it from the database.
  • code to redirect to the entry display if the post isn't deleted

🌸 Implementation Recap 🌸

at this point, we've built an app that lets a user do the following things when writing a blog:

  • creating an entry
  • editing an existing entry
  • deleting an entry
  • listing all blog entries
  • displaying a single entry

congratulations β€” you've added all the basic functionality! πŸŽ‰ now, let's secure critical functions of this blog by putting a login in front of them.


πŸ” Authentication πŸ”

now that the core functionality is done, we need to secure critical functions; visitors shouldn't be allowed to create or modify content, only authorized users of the Danceyland blog. no randoms touching the posts, bestie, that's the rule.

Dancer2 has several plugins available that help you add user authentication to your applications. for the Danceyland blog our needs are rather simple, so we'll use Dancer2::Plugin::Auth::Tiny as our authentication system of choice. iykyk πŸ’…

Dancer2::Plugin::Auth::Tiny is the little helper that gives us some extra syntax so we can just… say which routes need a logged-in user and which ones are chill for anyone πŸ’– It also hands us a bit of scaffolding to build the actual login procedure with. We'll circle back to that in a sec, bestie.

And we need a way to keep track of who is logged in. For that we gotta set up and work with sessions. ✨


🌸 Sessions 🌸

Sessions are how we sneak persistence into our web apps. They show up in a million different shapes β€” remembering who's currently logged in, remembering what you typed into a form across the pages of a multi-page form, all of it. Sessions = the mechanism for "remembering" stuff. 🧠✨

Sessions need a storage mechanism to power them. Some common storage engines are memory caches, files, and databases (SQL and NoSQL both, no cap).

And even though sessions are generally managed server side, you can also find them client side in secure cookies and browser local storage. πŸ‘‘

For this tutorial we're using Dancer2's YAML session engine, Dancer2::Session::YAML. Keeping our sessions in YAML means it's easy to peek at what's inside a session while we're developing and debugging the blog. πŸ™ˆ

✨ Setting Up a Session Engine ✨

Session engines work a lot like template engines: you do a lil bit of setup in your application's config file, and then they're just… available everywhere in the app. πŸ’…

To set up the YAML session engine, add this to your config.yml:

session: "YAML"

engines:
  session:
    YAML:
      cookie_name: dlblog.session
Heads up! You can only have one engines section, so this has to get combined with your existing template configuration. 🫢

The section should now look like:

template: "template_toolkit"
session: "YAML"
engines:
  template:
    template_toolkit:
      # Note: start_tag and end_tag are regexes
      start_tag: '<%'
      end_tag:   '%>'
  session:
    YAML:
      cookie_name: dlblog.session

πŸŽ€ Storing and Retrieving Session Data πŸŽ€

We can use our session to store info across multiple requests. Iconic behaviour, honestly.

Store session data with the session keyword:

session user => 'admin';

And retrieving session data? Also the session keyword:

my $user = session 'user';

You can double check the username actually got written to the session by looking at the session file created on disk. If you peek in your project directory you'll notice a shiny new sessions/ directory. There should be exactly one file in there: your current session. Run this:

$ cat sessions/<some session id>.yml

And you'll get a file that looks like:

---
user: admin

The session filename matches the session ID, which lives in a cookie that gets delivered to the client browser whenever your application is accessed. If you've got browser dev tools open while you hit your development site, you can inspect that cookie and see it for yourself. πŸ”

YAML files are great for sessions while developing, but they are not a good choice for production. We'll look at some other options when we talk about deploying to production later in this tutorial. πŸ’€

🌈 Storing application users 🌈

Since we're already using a database to store our blog contents, it just makes sense to track our application users there too. Let's create a super simplistic table to store user data in db/users.sql:

CREATE TABLE users (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    username VARCHAR NOT NULL UNIQUE,
    password VARCHAR NOT NULL
);

Then run this from our shell:

sqlite3 db/dlblog.db < db/users.sql

By declaring password to be an unbounded varchar field, we allow for passwords or passphrases of any length. πŸ“ And notice we don't track admin status, rights, or anything like that β€” if you can log in, you can administer the blog. That's it. That's the whole permission model. 😭

We'll need to regenerate the DBIx::Class::Result classes so we can create objects that represent users. Run this in your shell from the project directory:

dbicdump -o dump_directory=./lib \
    -o components='["InflateColumn::DateTime"]' \
    DLBlog::Schema dbi:SQLite:db/dlblog.db '{ quote_char => "\"" }'

You should now have an extra source file in your project directory: lib/DLBlog/Schema/Result/User.pm. ✨

πŸ¦‹ Password management with Dancer2::Plugin::CryptPassphrase πŸ¦‹

It is best practice to store passwords encrypted, lest someone with database access look at your users table and steal account credentials. Instead of rolling our own (please don't πŸ™ˆ), we'll use one of the many great options on CPAN.

Dancer2::Plugin::CryptPassphrase gives convenient access to Crypt::Passphrase inside your Dancer2 applications. We'll use the latter to generate a password hash for any new users we create.

Install the above modules:

cpanm Dancer2::Plugin::CryptPassphrase

and add the module to your cpanfile:

requires "Dancer2::Plugin::CryptPassphrase";

This extension needs config telling it which password encoder to use. Add this to the plugins section of the environments/development.yml file:

CryptPassphrase:
  encoder:
    module: Argon2
    parallelism: 2

From your shell, running the following will produce an encrypted password string:

perl -MCrypt::Passphrase -E \
    'my $auth=Crypt::Passphrase->new(encoder=>"Argon2"); say $auth->hash_password("test")'

(substitute any other password for "test" you'd rather use πŸ’œ)

That can then be filled in as the password value in the below SQL. From your shell:

sqlite3 db/dlblog.db

sqlite> INSERT INTO users (username, password)
VALUES (
    'admin',
    '$argon2id$v=19$m=262144,t=3,p=1$07krd3DaNn3b9JplNPSjnA$CiFKqjqgecDiYoV0qq0QsZn2GXmORkia2YIhgn/dbBo'
); -- admin/test

sqlite> .quit

πŸ‘‘ Dancer2::Plugin::Auth::Tiny πŸ‘‘

We also need to install Dancer2::Plugin::Auth::Tiny. From your shell:

cpanm Dancer2::Plugin::Auth::Tiny

Then add this to your cpanfile:

requires "Dancer2::Plugin::Auth::Tiny";

πŸ’– Implementing Login πŸ’–

We need two routes to implement the login process: a GET route to display a login form, and a POST route to process the form data. But before all that, we need to include the plugins we need for authentication. Below your other use statements, add the following:

use Dancer2::Plugin::Auth::Tiny;
use Dancer2::Plugin::CryptPassphrase;

We'll need some HTML to create a login form. Add the following to a new template file, views/login.tt:

<div id="login">
    <% IF login_error %>
    <div>
        Invalid username or password
    </div>
    <% END %>
    <form method="post" action="<% request.uri_for('/login') %>">
        <div>
            <label for="username">Username</label>
            <input type="text" name="username" id="username">
        </div>
        <div>
            <label for="password">Password</label>
            <input type="password" name="password" id="password">
        </div>
        <input type="hidden" name="return_url" id="return_url" value="<% return_url %>">
        <button type="submit">Login</button>
    </form>
</div>

Next, we need a route to display the login template:

get '/login' => sub {
    template 'login' => { return_url => params->{ return_url } };
};

So here's the tea β˜• if a user tries to access a route that requires a login and they aren't currently logged in, they get redirected to this get '/login' route, and the URL they originally tried to access gets stored in a generic parameter named return_url. After a successful login attempt, the user gets redirected to the page they were originally trying to gain access to. And return_url is stored in the form as a hidden field. 🫢

Finally, when a login request gets submitted, we attempt to validate it via a POST route:

post '/login' => sub {
    my $username = body_parameters->get('username');
    my $password = body_parameters->get('password');
    my $return_url = body_parameters->get('return_url');

    my $user = resultset( 'User' )->find({ username => $username });
    if ( $user and verify_password( $password, $user->password) ) {
        app->change_session_id;
        session user => $username;
        info "$username successfully logged in";
        return redirect $return_url || '/';
    }
    else {
        warning "Failed login attempt for $username";
        template 'login' => { login_error => 1, return_url => $return_url };
    }
};

We read the form values passed as body parameters, and attempt to look up a user with the provided username using find() with a special invokation; this time we specifically want to attempt to find a single row based on the value provided to the username column. 🎯

If a user is found, we use the verify_password() function from Crypt::Passphrase (provided via Dancer2::Plugin::CryptPassphrase) to try to compare password hashes; the passwords themselves are never directly checked against one another. Instead, the password entered by the user gets hashed using the same algorithm as when the password was hashed and stored in the database. verify_password() takes two arguments: the password the user entered, and the password hash we previously saved (stored in $user->password). If the hashes match, we've validated our user and we can proceed to log them in. βœ…

And this is where our session comes into play, bestie. We can store the name of the user that just authenticated in our session. Any time this user visits the site again, the session engine looks for a valid session with the ID provied in the cookie, and if a session is found, our application will look up the name of the user stored in that session; further activity will be tracked as that logged in user. πŸ’…βœ¨

To accomplish this, we first should generate a new session ID as a matter of good practice. Since the level of security granted is changing, using a new session ID guarantees another browser or user that may have the same session ID isn't accidentally granted admin permissions to our app (this practice stops a whole class of attacks against your webapp). πŸ”₯

Next, we stash the logged in username in our session via the session keyword, log an audit message (at the info level) that says a user logged in, and finally redirect them to their intended location (or, back to the post listing by default). On future requests, this browser will be treated as being logged in as the provided username. πŸŽ€


If the password check isn't successful, best practice is to log a message saying a login attempt failed, then redisplaying the login page with an error.


🌸 Implementing Logout 🌸

To logout a user, we need to remove the session for the logged in user, which creates a new session (containing no information about a logged in user) and issues a new cookie with the new session ID.

All that is needed is a simple GET route:

get '/logout' => sub {
    app->destroy_session;
    redirect uri_for "/";
};

In this route, we ask Dancer2 to destroy the user's session, then redirect them to the landing page. When the landing page request is served, a new session is created, and a new cookie delivered to the user's browser.

bye bestie, session deleted, cookie reissued, no notes πŸ’…


πŸ”’ Securing Routes πŸ”’

We earlier established that blog maintenance capabilities should be restricted to logged in/authenticated admin users. For the Danceyland blog, this amounts to:

  • Create
  • Update
  • Delete

Dancer2::Plugin::Auth::Tiny provides us with a little syntactic sugar to make this happen: needs login.

You can decorate the appropriate routes as such:

get '/create' => needs login => sub {

This tells Dancer2 that when this route is accessed, if we don't have a logged in user, redirect them to the /login route.

fr fr this is the whole security model, one little needs login and the gate is UP 🫢

✨ Recap ✨

Your finished application code should look like this:

package DLBlog;
use Dancer2;
use Dancer2::Plugin::DBIx::Class;
use Dancer2::Plugin::Auth::Tiny;
use Dancer2::Plugin::CryptPassphrase;
use Feature::Compat::Try;

get '/' => sub {
    # Give us the most recent first
    my @entries = resultset('Entry')->search(
        {},
        { order_by => { -desc => 'created_at' } },
    )->all;
    template 'index', { entries => \@entries };
};

get '/entry/:id[Int]' => sub {
    my $id = route_parameters->get('id');
    my $entry = resultset( 'Entry' )->find( $id );
    template 'entry', { entry => $entry };
};

get '/create' => needs login => sub {
    # Vars are passed to templates automatically
    template 'create_update', { post_to => uri_for '/create' };
};

post '/create' => needs login => sub {
    my $params = body_parameters();
    var $_ => $params->{ $_ } foreach qw< title summary content >;

    my @missing = grep { $params->{$_} eq '' } qw< title summary content >;
    if( @missing ) {
        var missing => join ",", @missing;
        warning "Missing parameters: " . var 'missing';
        forward '/create', {}, { method => 'GET' };
    }

    my $entry = do {
        try {
            resultset('Entry')->create( $params->as_hashref );
        }
        catch( $e ) {
            error "Database error: $e";
            var error_message => 'A database error occurred; your entry could not be created',
            forward '/create', {}, { method => 'GET' };
        }
    };

    debug 'Created entry ' . $entry->id . ' for "' . $entry->title . '"';
    redirect uri_for "/entry/" . $entry->id; # redirect does not need a return
};

get '/update/:id[Int]' => needs login => sub {
    my $id = route_parameters->get('id');
    my $entry = resultset( 'Entry' )->find( $id );
    var $_ => $entry->$_ foreach qw< title summary content >;
    template 'create_update', { post_to => uri_for "/update/$id" };
};

post '/update/:id[Int]' => needs login => sub {
    my $id = route_parameters->get('id');
    my $entry = resultset( 'Entry' )->find( $id );
    if( !$entry ) {
        status 'not_found';
        return "Attempt to update non-existent entry $id";
    }

    my $params = body_parameters();
    var $_ => $params->{ $_ } foreach qw< title summary content >;
    my @missing = grep { $params->{$_} eq '' } qw< title summary content >;
    if( @missing ) {
        var missing => join ",", @missing;
        warning "Missing parameters: " . var 'missing';
        forward "/update/$id", {}, { method => 'GET' };
    }

    try {
        $entry->update( $params->as_hashref );
    }
    catch( $e ) {
        error "Database error: $e";
        var error_message => 'A database error occurred; your entry could not be updated',
        forward "/update/$id", {}, { method => 'GET' };
    }

    debug 'Updated entry ' . $entry->id . ' for "' . $entry->title . '"';
    redirect uri_for "/entry/" . $entry->id; # redirect does not need a return
};

get '/delete/:id[Int]' => needs login => sub {
    my $id = route_parameters->get('id');
    my $entry = resultset( 'Entry' )->find( $id );
    template 'delete', { entry => $entry };
};

post '/delete/:id[Int]' => needs login => sub {
    my $id = route_parameters->get('id');
    my $entry = resultset( 'Entry' )->find( $id );
    if( !$entry ) {
        status 'not_found';
        return "Attempt to delete non-existent entry $id";
    }

    # Always default to not destroying data
    my $delete_it = body_parameters->get('delete_it') // 0;

    if( $delete_it ) {
        $entry->delete;
        redirect uri_for "/";
    } else {
        # Display our entry again
        redirect uri_for "/entry/$id";
    }
};

get '/login' => sub {
    template 'login' => { return_url => params->{ return_url } };
};

post '/login' => sub {
    my $username = body_parameters->get('username');
    my $password = body_parameters->get('password');
    my $return_url = body_parameters->get('return_url');

    my $user = resultset( 'User' )->find({ username => $username });
    if ( $user and verify_password( $password, $user->password) ) {
        app->change_session_id;
        session user => $username;
        info "$username successfully logged in";
        return redirect $return_url || '/';
    }
    else {
        warning "Failed login attempt for $username";
        template 'login' => { login_error => 1, return_url => $return_url };
    }
};

get '/logout' => sub {
    app->destroy_session;
    redirect uri_for "/";
};

true;

LOOK AT HER. the whole app. she's beautiful. she ATE πŸ’–


πŸŽ€ Finishing Touches πŸŽ€

At this point, Danceyland has a fully functional blog engine, and your users are chomping at the bit to start using it. Before we let them loose on your new creation, let's pretty it up, write some tests, and get it deployed to a production environment.

πŸ’… Adding some style πŸ’…

We've written all of our views up to this point with basic HTML; no effort has been made to style our blog or make it attractive. That is beyond the scope of this tutorial.

If you clone the tutorial repository, you'll notice that all views have styling markup that isn't provided in the tutorial. This was done to show you one way styling could be done, and to give you an attractive example application to learn from.

Bootstrap <https://getbootstrap.com> was used to provide styling for the tutorial; it's well known, easy to understand, and easy to find help for. If you want to learn more about how Bootstrap works, check out their website for more details.

ngl the tutorial views are serving plain-HTML minimalism and honestly? valid. the repo version is the glow-up ✨

πŸ§ͺ Testing Your Application πŸ§ͺ

When developing your application, writing and running tests helps you work through difficult parts of your application, and allows you to verify that your Dancer2 application is behaving as you designed it to. In a production environment, running tests before installation or upgrade help ensure there are no surprises when you start/restart the application server. Let's see how we can write some basic functionality tests.

πŸ¦‹ Using Test::WWW::Mechanize::PSGI πŸ¦‹

Test::WWW::Mechanize::PSGI lets us test our Dancer2 applications without having to spin up a test web server. It is loaded with convenience methods to make it easy to navigate and test our Plack/PSGI applications. Run the following to install this from your shell:

cpanm Test::WWW::Mechanize::PSGI

It must also be added to the on "test" section of your cpanfile:

requires "Test::WWW::Mechanize::PSGI" => "0";

Before we do any testing for our blog, we'll need to create some testing infrastructure so we don't step on any of our production or test data. We need a testing database to run tests against so we don't corrupt other development or production data. In your shell, run the following:

mkdir t/db
cp db/dlblog.db t/db/test.db

(or, you can use the test database that ships with the DLBlog GitHub repository)

You'll also need a config file that is representative of your testing environment. Create environments/test.yml and add the following:

logger: "console"
log: "warning"
show_stacktrace: 0
no_server_tokens: 1

plugins:
  DBIx::Class:
    default:
      dsn: "dbi:SQLite:dbname=t/db/test.db"
      schema_class: "DLBlog::Schema"
      dbi_params:
        RaiseError: 1
        AutoCommit: 1
  CryptPassphrase:
    encoder:
      module: Argon2
      parallelism: 2

This is a hybrid of your production and development configs:

  • It still logs to the console, but at a higher level so only errors are seen
  • We hide any stacktraces and disable server tokens/headers
  • We tell DBIx::Class to look at our test database

To run the default tests from your shell:

DANCER_ENVIRONMENT=test prove -lv

This runs all tests in the t/ subdirectory with maximum verbosity, and runs with our test environment configuration.

Dancer2 creates applications with two default tests. The first, t/001_base.t, ensures that your Dancer2 application compiles successfully without errors. The other test, t/002_index_route.t, ensures that the default route of your application is accessible. These are the two most basic tests an application can have, and validate very little about the functionality of your app. We're going to make two new tests: one just to test the nuances of the login process (as security is critical), and another to test basic blog functionality.

πŸ”₯ Login Tests πŸ”₯

Let's verify the login process behaves as intended. Edit t/003_login.t and add the following:

use strict;
use warnings;
use Test::More;
use Test::WWW::Mechanize::PSGI;

security testing is the moment, bestie β€” we do NOT ship a login route untested 🫢

use DLBlog;
my $mech = Test::WWW::Mechanize::PSGI->new(
    app => DLBlog->to_app,
);

$mech->get_ok('/create', 'Got /create while not logged in');
$mech->content_contains('Password', '...and was presented with a login page');
$mech->submit_form_ok({
    fields => {
        username => 'admin',
        password => 'foobar',
    }}, '...which we gave invalid credentials');
$mech->content_contains('Invalid username or password', '...and gave us an appropriate error');
$mech->submit_form_ok({
    fields => {
        username => 'admin',
        password => 'test',
    }}, '...so we give it real credentials');
$mech->content_contains('form', '...and get something that looks like the create form' );
$mech->content_contains('Content', 'Confirmed this is the create form');

done_testing;

okay so we're pulling in two test modules here: Test::More, which gives us the basic ✨test functionality✨, and Test::WWW::Mechanize::PSGI, which is the one doing all our heavy lifting πŸ‹οΈπŸ’–

First things first, bestie β€” we gotta make an instance of a Mechanize object:

my $mech = Test::WWW::Mechanize::PSGI->new(
    app => DLBlog->to_app,
);

That builds us an instance of the Mechanize user agent πŸ¦‹ and points it straight at an instance of the Danceyland blog app (DLBlog).

So remember how we said some routes can't be accessed by unauthorized / non-logged-in users? Yeah. Let's actually test that πŸ‘€

$mech->get_ok('/create', 'Got /create while not logged in');
$mech->content_contains('Password', '...and was presented with a login page');

This is testing the "needs login" condition on the /create route. If we haven't logged in, we should get yeeted over to a login page πŸšͺ get_ok makes sure the route is actually accessible, and content_contains goes hunting for a password field. iconic, truly πŸ’…

And if a login attempt fails we should get an error message back. So let's stuff the form with invalid credentials and verify:

$mech->submit_form_ok({
    fields => {
        username => 'admin',
        password => 'foobar',
    }}, '...which we gave invalid credentials');
$mech->content_contains('Invalid username or password', '...and gave us an appropriate error');

submit_form_ok takes a hashref of fields, drops the values you gave it into them, then clicks the right submit button πŸ–±οΈβœ¨ Then we check the resulting page content to confirm that yes, fr fr, the invalid username/password error message is right there.

So we know login handles failed attempts okay now. But what about a login with valid credentials? πŸ‘‘

$mech->submit_form_ok({
    fields => {
        username => 'admin',
        password => 'test',
    }}, '...so we give it real credentials');
$mech->content_contains('form', '...and get something that looks like the create form' );
$mech->content_contains('Content', 'Confirmed this is the create form');

We pass the default admin/test credentials πŸŽ€ then peep the page we get taken to. The create page should have a form, and one of those fields should be named Content. content_contains looks for both of those on the resulting page and passes if they're there. slay.

And finally, we gotta announce that we're done running tests:

done_testing;

Now let's run just this one test. From your shell:

DANCER_ENVIRONMENT=test prove -lv t/003_login.t

And you should see the following output:

t/003_login.t ..
ok 1 - Got /create while not logged in
ok 2 - ...and was presented with a login page
[DLBlog:2287783] warning @2025-02-06 22:34:25> Failed login attempt for admin in /path/to/DLBlog/lib/DLBlog.pm l. 134
ok 3 - ...which we gave invalid credentials
ok 4 - ...and gave us an appropriate error
ok 5 - ...so we give it real credentials
ok 6 - ...and get something that looks like the create form
ok 7 - Confirmed this is the create form
1..7
ok
All tests successful.
Files=1, Tests=7,  1 wallclock secs ( 0.02 usr  0.00 sys +  0.92 cusr  0.10 csys =  1.04 CPU)
Result: PASS

Those little labels help us see which tests are running, which makes examining failures way easier when they happen πŸ™ˆ You'll also spot a log message that got produced when our login attempt failed, plus a big old PASS at the end showing all the tests ran successfully. we ate and left no crumbs πŸ“


πŸ¦‹ Blog Tests πŸ¦‹

Using all the same techniques we just learned writing the login test, we're gonna make a test for basic blog functionality ✨ The complete test is gonna run through a basic workflow of the Danceyland blog:

  1. Login
  2. Create an entry
  3. Edit an entry
  4. Delete an entry

Add the following to t/004_blog.t in your project directory:

use strict;
use warnings;
use Test::More;
use Test::WWW::Mechanize::PSGI;

use DLBlog;
my $mech = Test::WWW::Mechanize::PSGI->new(
    app => DLBlog->to_app,
);

subtest 'Landing page' => sub {
    $mech->get_ok('/', 'Got landing page');
    $mech->title_is('Danceyland Blog', '...for our blog');
    $mech->content_contains('Test Blog Post','...and it has a test post');
};

subtest 'Login' => sub {
    $mech->get_ok('/login', 'Visit login page to make some changes');
    $mech->submit_form_ok({
        fields => {
            username => 'admin',
            password => 'test',
        }}, '...so we give it a user');
};

subtest 'Create' => sub {
    $mech->get_ok('/create', 'Write a new blog post');
    $mech->submit_form_ok({
        fields => {
            title => 'Another Test Post',
            summary => 'Writing a blog post can be done by tests too',
            content => 'You can create blog entries programmatically with Perl!',
        }}, '...then write another post');
    $mech->base_like( qr</entry/\d+$>, '...and get redirected to the new entry' );
};

my $entry_id;
subtest 'Update' => sub {
    ($entry_id) = $mech->uri =~ m</(\d+)$>;
    $mech->get_ok("/update/$entry_id", 'Navigating to the update page for this post');
    $mech->submit_form_ok({
        fields => {
            title => 'Yet ANOTHER Test Post',
        }}, '...then update yet another post');
    $mech->base_like( qr</entry/${entry_id}$>, '...and get redirected to the entry page' );
    $mech->has_tag('h1','Yet ANOTHER Test Post', '...and it has the updated title');
};

subtest 'Delete' => sub {
    $mech->get_ok("/delete/$entry_id", "Go delete page for new entry");
    $mech->submit_form_ok({
        fields => {
            delete_it => 'yes',
        }}, '...then delete it!');
    $mech->get_ok("/entry/$entry_id", '...then try to navigate to the entry');
    $mech->content_contains('Invalid entry','...and see the post is no longer there');
};

done_testing;

So you'll notice that this time we organized our tests into multiple subtests that keep related tests together 🫢 Is it strictly necessary? No bestie, but it's highkey helpful when you're working on big test files πŸ’–

This test introduces us to a few additional types of tests:

  • base_like β€” this test method examines the resulting URL from the previous Mechanize operation. In the Create subtest we submit a blog entry, and we should get taken to the view page for that new entry. By checking the resulting URL we can verify that yeah, we were taken to the view page after the post got created πŸŽ€
  • uri β€” this returns the full URI of the previous Mechanize operation. From that URI we can extract the ID of the last blog entry we created. We're gonna need that to test the update and delete operations πŸ‘‘
  • has_tag β€” this method looks for a tag matching the type specified, and checks whether the text inside that tag matches what the test specifies. Our h1 on the entry page should have the blog post title, so in the update test we check that the updated title is present. periodt ✨

Run the test from your shell:

DANCER_ENVIRONMENT=test prove -lv t/004_blog.t

And you should see the following output:

t/004_blog.t ..
# Subtest: Landing page
    ok 1 - Got landing page
    ok 2 - ...for our blog
    ok 3 - ...and it has a test post
    1..3
ok 1 - Landing page
# Subtest: Login
    ok 1 - Visit login page to make some changes
    ok 2 - ...so we give it a user
    1..2
ok 2 - Login
# Subtest: Create
    ok 1 - Write a new blog post
    ok 2 - ...then write another post
    ok 3 - ...and get redirected to the new entry
    1..3
ok 3 - Create
# Subtest: Update
    ok 1 - Navigating to the update page for this post
    ok 2 - ...then update yet another post
    ok 3 - ...and get redirected to the entry page
    ok 4 - ...and it has the updated title
    1..4
ok 4 - Update
# Subtest: Delete
    ok 1 - Go delete page for new entry
    ok 2 - ...then delete it!
    ok 3 - ...then try to navigate to the entry
    ok 4 - ...and see the post is no longer there
    1..4
ok 5 - Delete
1..5
ok
All tests successful.
Files=1, Tests=5,  1 wallclock secs ( 0.01 usr  0.01 sys +  0.77 cusr  0.10 csys =  0.89 CPU)
Result: PASS

You'll notice that not only is the code conveniently grouped by subtest, but so is the output πŸ¦‹ the way that just works.

There's a lot more you can still test, ngl. Look for some additional ideas at the end of this tutorial πŸ‘€

πŸš€ DEPLOYMENT πŸš€

Okay bestie, we built the whole thing, we wrote tests to make sure it doesn't fall apart, and now?? Now we make it public. Like, on a server. For real people. Let's go ✨

πŸŽ€ Creating Production Configuration πŸŽ€

So the default Dancer2 config is super chatty β€” it hands the developer tons of info to help with debugging while you're building stuff. But in production? That's way too much info, and some of it is exactly the kind of thing someone trying to compromise your app would love to see. πŸ™ˆ So let's make an environment specifically for production and turn the detail dial all the way down.

Also, quick note: if you were using a database server instead of SQLite, you'd want to update your database credentials in your production config so they point at your production database server. Just saying. πŸ’…

Replace your environments/production.yml file with this:

# configuration file for production environment
behind_proxy: 1

# only log info, warning and error messsages
log: "info"

# log message to a file in logs/
logger: "file"

# hide errors
show_stacktrace: 0

# disable server tokens in production environments
no_server_tokens: 1

# Plugin configuration
plugins:
  DBIx::Class:
    default:
      dsn: "dbi:SQLite:dbname=db/dlblog.db"
      schema_class: "DLBlog::Schema"
      dbi_params:
        RaiseError: 1
        AutoCommit: 1
  CryptPassphrase:
    encoder:
      module: Argon2
      parallelism: 2

So what did we just change? Let's break it down, because each of these is iconic in its own way:

  • Running behind a reverse proxy β€” We're gonna deploy our app alongside NGINX, and running that way means Dancer2 has to interact with HTTP headers differently than it would if it were standing alone. This setting tells Dancer2 to behave the way it should when it's sitting behind an NGINX (or any other) reverse proxy. Teamwork, babes. 🀝
  • Logging only informational or more severe messages β€” In a production environment, logging debug messages and core Dancer2 messages is rarely needed. Like, nobody's reading that at 3am.
  • Logging to a file β€” Servers run in the background, not in a console window, bestie. So we need somewhere to catch those log messages. Bonus: file logs can be shipped off to another service (hi, Kibana) for analysis. πŸ“Š
  • No stacktraces β€” If a fatal error happens, the stacktraces Dancer2 produces hand a potential attacker a little guided tour of the insides of your application. 😭 By setting show_stacktrace to 0, all errors show only the public/500.html page. Nothing to see here, move along.
  • Disabling server tokens β€” Setting no_server_tokens stops Dancer2 from adding the X-Powered-By header and, when you're using Dancer2's standalone server, from setting the Server header to the Dancer2 version.
Heads up: it does not touch the Server header when running under an external PSGI server.

And our plugin configuration? Unchanged from the development environment. Same vibes, different environment. πŸ¦‹

πŸ“¦ Installing the Danceyland Blog πŸ“¦

On your production server, make sure you've got a version of Perl installed, then copy your project files over by whatever method makes sense for your situation. (Scp? Rsync? Git? Carrier pigeon? You do you. πŸ•ŠοΈ)

Run this from inside the project directory to install the application's dependencies:

cpanm --installdeps . --with-test --with-all-features

Now go get a cup of coffee β˜• while that churns β€” when you come back, resolve any dependency issues, then run your tests to make sure the Danceyland blog is actually functional:

DANCER_ENVIRONMENT=test prove -l

Your output should look something like this:

./t/001_base.t ......... ok
./t/002_index_route.t .. ok
./t/003_login.t ........ 1/? [DLBlog:2291513] warning @2025-02-06 22:59:09> Failed login attempt for admin in /path/to/DLBlog/lib/DLBlog.pm l. 134
./t/003_login.t ........ ok
./t/004_blog.t .........
# Subtest: Landing page
./t/004_blog.t ......... 1/? # Subtest: Login
./t/004_blog.t ......... 2/? # Subtest: Create
# Subtest: Update
# Subtest: Delete
./t/004_blog.t ......... ok
All tests successful.
Files=4, Tests=15,  3 wallclock secs ( 0.02 usr  0.00 sys +  2.30 cusr  0.32 csys =  2.64 CPU)
Result: PASS

Result: PASS?? We love to see it. πŸ”₯

🧚 Deploying with a PSGI Server 🧚

Okay so plackup β€” by default β€” runs a development server for developing your application. It is not suitable for any public-facing deployment. Do not do it. I'm serious. πŸ™…

There are a bunch of great options over on the Plack <https://plackperl.org/> website. For our example we'll use Starman, because it gives reasonable performance and works on nearly any server OS.

We'll pair Starman with Server::Starter, which gives you a robust way to manage server processes. Iconic combo. πŸ‘‘

Install both of these modules:

cpanm Starman Server::Starter

And add them to the blog's cpanfile:

requires "Starman";
requires "Server::Starter";

Assuming we're deploying to a Debian server, you can use the following to start the Danceyland blog in the background:

sudo start_server \
    --daemonize \
    --dir=/path/to/DLBlog \
    --port=5000 \
    --log-file=/var/log/dlblog.log \
    --pid-file=/var/run/dlblog.pid \
    --status-file=/var/run/dlblog.status \
    -- plackup -s Starman--user www-data --group www-data -E production \
    bin/app.psgi

Once it's operational, the server can be restarted with:

start_server --restart --pid-file=/var/run/dlblog.pid --status-file=/var/run/dlblog.status

Or stopped with:

start_server --stop --pid-file=/var/run/dlblog.pid

Start, restart, stop β€” full control, no chaos. ✨

🌈 Configuring Reverse Proxy with NGINX 🌈

Finally, let's put NGINX out in front of our Dancer2 application. This improves the security and the performance of our application. Glow up. πŸ’–

server {
    listen 80;
    server_name example.com;

    location / {
        proxy_pass http://127.0.0.1:5000;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    }
}

If this server is public facing, you should also configure it with HTTPS β€” https://letsencrypt.org in Let's Encrypt makes this free and easy. Free TLS? Say less. πŸ”’

More advanced setups are totally possible (like serving static content from NGINX instead of Dancer2), but that's beyond the scope of this tutorial. We stay on topic here. πŸ’…


πŸ‘‘ WHAT YOU'VE BUILT πŸ‘‘

Congratulations!!! πŸŽ‰ You have built a primitive but very functional blog engine that protects maintenance functions behind a user login, using Dancer2 and other plugins and modules in its ecosystem. You've learned a bunch of important building blocks that are gonna be crucial for building other applications. Look at you go. πŸ₯Ή

πŸ¦‹ WHERE TO GO NEXT πŸ¦‹

This application can be used as a springboard and a reference for future Dancer2 projects. There are still a number of improvements and additional features that can be added to this blog. A few ideas, bestie:

  • Paginate the list of blog entries β€” After a while, the list of blog entries can get long. Using Data::Page or other Perl modules, break the list of entries into reasonable page sizes so it's easier and quicker to navigate.
  • Add a search function to the blog β€” Add a search bar to the UI, then use the search() method in DBIx::Class to find blog entries based on what the used input.
  • Improve application security by sanitizing input parameters β€” Using a regex or a validation framework (such as Dancer2::Plugin::DataTransposeValidator), scrub all input before using it in a database operation.
  • Use database slugs in URLs instead of IDs β€” A database slug is a human-readable identifier (such as a URL encoding of the entry title) that can be used to identify an entry rather than the numerical ID. They're easier to remember than IDs, they're better for SEO of your content, and they make your application more secure by hiding some database implementation details from an attacker (such as a row ID).
  • Move business logic to business layer; call business object from Dancer2 β€” In larger applications, business logic (like creating a blog post) may be put in an object (such as DBIx::Class::Result or DBIx::Class::ResultSet objects, or other Moo or Moose objects), and that object gets instantiated and called from Dancer2. This helps to decouple tasks in an application, and allows for better testing of business logic.
  • Write more tests to catch error conditions for our blog β€” What happens when you pass a string to a route expecting an ID? What happens when you don't fill out all the fields on the create or update pages? Write tests to check these conditions using what you've learned from writing the existing tests, and make sure the error handling behaves as expected.

For another example of a blog engine in Dancer2, check out Dancer2::Plugin::LiteBlog from our project founder, Sukria. 🫢

Happy Dancing! πŸ’ƒπŸ•Ί


✨ AUTHOR ✨

Dancer Core Developers

This software is copyright (c) 2026 by Alexis Sukrieh.

This is free software; you can redistribute it and/or modify it under the same terms as the Perl 5 programming language system itself.


And that's the whole tutorial, bestie!! 🌸 This was perl v5.42.3's rendition, dated 2026-09-20, and honestly? It ate. Go build something gorgeous with Dancer2 β€” you've got the whole toolbox now. Happy Dancing, fr fr. πŸ’–πŸŽ€πŸ¦‹

πŸ“¦ where this page comes from