Creating a Basic Blog

You can watch the video version of this tutorial within the getting started section.

This tutorial will go through every step to create a fully functioning backend for a blog with tags and categories as a brand new project.

Your system paths and setup may differ, please take what you see with a pinch of salt and adjust where needed.

Creating The Project

cd ~/Sites
composer create-project laravel/laravel ./blogstrom
cd ./blogstrom
valet link
php artisan make:auth
php artisan storage:link
npm install
npm run dev
git init
git add .
git commit -m "Clean Laravel Install"

This should get you setup with a clean Laravel installation, you'll now need to edit the .env to update your settings.

Make sure you setup your database and APP_URL before you continue.

Now when I visit http://blogstrom.dev I see the default Laravel page, You can then click the Register button and create yourself account for ease.

Installing Maelstrom

composer require maelstrom-cms/toolkit
npm install @maelstrom-cms/toolkit
rm -f webpack.mix.js
php artisan vendor:publish --tag=maelstrom-stubs
php artisan vendor:publish --tag=maelstrom-config
npm run dev
php artisan migrate
git add .
git commit -m "Added Maelstrom"

You can confirm Maelstrom is installed by typing php artisan route:list | grep "maelstrom" and you should see some routes.

Creating Models

We're going to need 2 models

  1. Post
  2. Category
php artisan make:model Post -m
php artisan make:model Category -m

We can now setup the database for them by editing the generated migrations e.g.

Schema::create('posts', function (Blueprint $table) {
    $table->bigIncrements('id');
    $table->string('name');
    $table->string('slug');
    $table->string('image');
    $table->longText('body');
    $table->boolean('is_sticky')->default(0);
    $table->bigInteger('category_id');
    $table->json('tags')->nullable();
    $table->softDeletes();
    $table->timestamps();
});

and

Schema::create('categories', function (Blueprint $table) {
    $table->bigIncrements('id');
    $table->string('name');
    $table->softDeletes();
    $table->timestamps();
});

We then need to add the SoftDeletes trait to the models to enable the trash.

use Illuminate\Database\Eloquent\SoftDeletes;

class Category extends Model
{
    use SoftDeletes;
}

and

use Illuminate\Database\Eloquent\SoftDeletes;

class Post extends Model
{
    use SoftDeletes;
}
git add .
git commit -m "Added Models"

We can now run these migrations with php artisan migrate

Creating the Category Panel

Firstly we'll the controller and the routes needed.

php artisan make:controller Admin\\CategoryController -r -m Category

This will give us app/Http/Controllers/Admin/CategoryController.php which we can now register as a route in web.php

We create a route group with the prefix of /admin and protect the route via a login session under the auth middleware.

Route::prefix('/admin')->middleware('auth')->group(function () {
   Route::resource('category', 'Admin\CategoryController');
});

If that worked, we can now visit http://blogstrom.dev/admin/category and you should see a blank page.

Our Controller

We can now jump into our controller and scaffold out the panel.

<?php

namespace App\Http\Controllers\Admin;

use App\Category;
use Maelstrom\Panel;
use ReflectionException;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use App\Http\Controllers\Controller;

class CategoryController extends Controller
{
    /**
     * @var Panel
     */
    private $panel;

    public function __construct()
    {
        $this->panel = maelstrom(Category::class);
    }

    /**
     * Display a listing of the resource.
     *
     * @return Response
     */
    public function index()
    {
        $this->panel->nameHeadings([
            [
                'label' => 'Name',
                'name' => 'name',
                'searchable' => true,
                'sortable' => true,
                'type' => 'EditLinkColumn'
            ]
        ]);

        return $this->panel->index('admin.category-index');
    }

    /**
     * Show the form for creating a new resource.
     *
     * @return Response
     */
    public function create()
    {
        return $this->panel->create('admin.category-form');
    }

    /**
     * Store a newly created resource in storage.
     *
     * @param Request $request
     * @return Response
     * @throws ReflectionException
     */
    public function store(Request $request)
    {
        $request->validate([
            'name' => 'required|max:255',
        ]);

        $this->panel->store($request->get('name') . ' has been created!');

        return $this->panel->redirect('edit');
    }

    /**
     * Display the specified resource.
     *
     * @param Category $category
     * @return Response
     */
    public function show(Category $category)
    {
        return redirect()->route('category.edit', $category);
    }

    /**
     * Show the form for editing the specified resource.
     *
     * @param Category $category
     * @return Response
     */
    public function edit(Category $category)
    {
        $this->panel->setEntry($category);

        return $this->panel->edit('admin.category-form');
    }

    /**
     * Update the specified resource in storage.
     *
     * @param Request $request
     * @param Category $category
     * @return Response
     * @throws ReflectionException
     */
    public function update(Request $request, Category $category)
    {
        $request->validate([
            'name' => 'required|max:255'
        ]);

        $this->panel->setEntry($category);

        $this->panel->update($category->name . ' has been updated');

        return $this->panel->redirect('edit');
    }

    /**
     * Remove the specified resource from storage.
     *
     * @param Category $category
     * @return void
     * @throws \Exception
     */
    public function destroy(Category $category)
    {
        $this->panel->setEntry($category);

        $message = $category->exists() ? ($category->name . ' has been deleted.') : ($category->name . ' has been restored.');

        $this->panel->destroy($message);

        return $this->panel->redirect(
            $category->exists() ? 'edit' : 'index'
        );
    }

    /**
     * Handles the bulk actions from the index table
     *
     * @return \Illuminate\Http\RedirectResponse|\Illuminate\Routing\Redirector
     */
    public function bulk()
    {
        $this->panel->handleBulkActions();

        return $this->panel->redirect('index');
    }
}
git add .
git commit -m "Added Routes and Controller"

Our Views

Before this will work, we'll also need to create some views for our index and form, picking a simple convention will help keep this cleaner, I quite like the below.

mkdir resources/views/admin
touch resources/views/admin/category-form.blade.php
touch resources/views/admin/category-index.blade.php

The Index

Firstly we'll focus on our index page, as per the other documentation we can extend the maelstrom::layouts.index to get started and quickly create a button to allow adding of new entries.

@extends('maelstrom::layouts.index')

@section('buttons')
    @include('maelstrom::buttons.button', [
        'url' => route('category.create'),
        'label' => 'Create Category'
    ])
@endsection

Now when we visit http://blogstrom.dev/admin/category we should see something like

The Form

Now we've got a create button, we should be able to click it and see a new blank page on the url http://blogstrom.dev/admin/category/create.

We can now add our form fields, which in this case is only 1 field so very easy!

@extends('maelstrom::layouts.form')

@section('content')

    @component('maelstrom::components.form', [
        'action' => $action,
        'method' => $method,
    ])

        @include('maelstrom::inputs.text', [
            'label' => 'Category Name',
            'name' => 'name',
            'required' => true,
        ])

    @endcomponent

@endsection

Before you can use the form, you'll need to update your $fillable array on the model to allow the fields to be saved.

use Illuminate\Database\Eloquent\SoftDeletes;

class Category extends Model
{
    use SoftDeletes;
    
    protected $fillable = ['name'];
}

Now you should be able create your first category!

Setting Up Bulk Actions

Now we will hook up the bulk method earlier to a route so we can access e.g.

Route::post('category/bulk', 'Admin\CategoryController@bulk')->name('category.bulk');

The route name should be defined the same as the resource so if the resource is named as Route::resource('pages'), then your bulk route should be named pages.bulk.

So we should end up with:

Route::prefix('/admin')->middleware('auth')->group(function () {
   Route::resource('category', 'Admin\CategoryController');
   Route::post('category/bulk', 'Admin\CategoryController@bulk')->name('category.bulk');
});

We'll now be able to bulk delete delete! Give it a try then access your trash!

When you access the item from the trash you can also restore it from the edit form.

git add .
git commit -m "Added Views"

Creating a Sidebar

Before this panel is complete, we'll need to add it to the sidebar, we can do this from the AppServiceProvider.php.

use Illuminate\Support\Facades\View;

public function boot()
{
    View::share('maelstrom_sidebar', [
        [
            'id' => 'content',
            'label' => 'Content',
            'type' => 'SubMenu',
            'icon' => 'edit',
            'children' => [
                [
                    'id' => 'categories',
                    'label' => 'Categories',
                    'url' => url('/admin/category'),
                    'icon' => 'folder-open',
                ]
            ],
        ],
    ]);
}

You should now see your sidebar!

git add .
git commit -m "Added Sidebar"

Creating a Post Panel

We'll start by adding a new item to our sidebar:

public function boot()
{
    View::share('maelstrom_sidebar', [
        [
            'id' => 'content',
            'label' => 'Content',
            'type' => 'SubMenu',
            'icon' => 'edit',
            'children' => [
                [
                    'id' => 'posts',
                    'label' => 'Posts',
                    'url' => url('/admin/posts'),
                    'icon' => 'read',
                ],
                [
                    'id' => 'categories',
                    'label' => 'Categories',
                    'url' => url('/admin/category'),
                    'icon' => 'folder-open',
                ],
            ],
        ],
    ]);
}

Then create a controller

php artisan make:controller Admin\\PostController -r -m Post

and add the routes for it

Route::prefix('/admin')->middleware('auth')->group(function () {
   Route::resource('category', 'Admin\CategoryController');
   Route::post('category/bulk', 'Admin\CategoryController@bulk')->name('category.bulk');
   
   Route::resource('post', 'Admin\PostController');
   Route::post('post/bulk', 'Admin\PostController@bulk')->name('post.bulk');
});

and then we can start with the same structured controller as the CategoryController

<?php

namespace App\Http\Controllers\Admin;

use App\Post;
use Maelstrom\Panel;
use ReflectionException;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use App\Http\Controllers\Controller;

class PostController extends Controller
{
    /**
     * @var Panel
     */
    private $panel;

    public function __construct()
    {
        $this->panel = maelstrom(Post::class);
    }

    /**
     * Display a listing of the resource.
     *
     * @return Response
     */
    public function index()
    {
        $this->panel->setTableHeadings([
            [
                'label' => 'Name',
                'name' => 'name',
                'searchable' => true,
                'sortable' => true,
                'type' => 'EditLinkColumn'
            ],
        ]);

        return $this->panel->index('admin.post-index');
    }

    /**
     * Show the form for creating a new resource.
     *
     * @return Response
     */
    public function create()
    {
        return $this->panel->create('admin.post-form');
    }

    /**
     * Store a newly created resource in storage.
     *
     * @param Request $request
     * @return Response
     * @throws ReflectionException
     */
    public function store(Request $request)
    {
        $request->validate([
            'name' => 'required|max:255',
            'slug' => 'required|max:255',
            'image' => 'required|numeric',
            'body' => 'required|max:1000',
            'category_id' => 'required|numeric',
        ]);

        $this->panel->store($request->get('name') . ' has been created!');

        return $this->panel->redirect('edit');
    }

    /**
     * Display the specified resource.
     *
     * @param Post $post
     * @return Response
     */
    public function show(Post $post)
    {
        return redirect()->route('post.edit', $post);
    }

    /**
     * Show the form for editing the specified resource.
     *
     * @param Post $post
     * @return Response
     */
    public function edit(Post $post)
    {
        $this->panel->setEntry($post);

        return $this->panel->edit('admin.post-form');
    }

    /**
     * Update the specified resource in storage.
     *
     * @param Request $request
     * @param Post $post
     * @return Response
     * @throws ReflectionException
     */
    public function update(Request $request, Post $post)
    {
        $request->validate([
            'name' => 'required|max:255',
            'slug' => 'required|max:255',
            'image' => 'required|numeric',
            'body' => 'required|max:1000',
            'category_id' => 'required|numeric',
        ]);

        $this->panel->setEntry($post);

        $this->panel->update($post->name . ' has been updated');

        return $this->panel->redirect('edit');
    }

    /**
     * Remove the specified resource from storage.
     *
     * @param Post $post
     * @return void
     * @throws \Exception
     */
    public function destroy(Post $post)
    {
        $this->panel->setEntry($post);

        $message = $post->exists() ? ($post->name . ' has been deleted.') : ($post->name . ' has been restored.');

        $this->panel->destroy($message);

        return $this->panel->redirect(
            $post->exists() ? 'edit' : 'index'
        );
    }

    /**
     * Handles the bulk actions from the index table
     *
     * @return \Illuminate\Http\RedirectResponse|\Illuminate\Routing\Redirector
     */
    public function bulk()
    {
        $this->panel->handleBulkActions();

        return $this->panel->redirect('index');
    }
}

Create our views

touch resources/views/admin/post-form.blade.php
touch resources/views/admin/post-index.blade.php

A simple post-index.blade.php template:

@extends('maelstrom::layouts.index')

@section('buttons')
    @include('maelstrom::buttons.button', [
        'url' => route('post.create'),
        'label' => 'Create Post'
    ])
@endsection

We're almost ready to move onto building the form - but first we need to configure our model a little

We need to

  • Set up the $fillable
  • Cast our tags to an array within $casts
  • Define our category relationship
  • Define our image relationship
<?php

namespace App;

use Maelstrom\Models\Media;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;

class Post extends Model
{
    use SoftDeletes;

    protected $fillable = [
        'name',
        'slug',
        'image',
        'is_sticky',
        'body',
        'category_id',
        'tags',
    ];

    protected $casts = [
        'tags' => 'array',
    ];

    public function category()
    {
        return $this->belongsTo(Category::class);
    }

    public function featuredImage()
    {
        return $this->belongsTo(Media::class, 'image');
    }
}

Now we've got our model, our controller and index we should again see something like:

If we edit our post-form.blade.php form and add the basic form components we should get something we can work with!

@extends('maelstrom::layouts.form')

@section('content')

    @component('maelstrom::components.form', [
        'action' => $action,
        'method' => $method,
    ])

    @endcomponent

@endsection

Now we can start to flesh our our form with:

  • Post Name (text)
  • URL Slug (text)
  • Featured Image (media manager)
  • Is Sticky? (toggle switch)
  • Body Content (wysiwyg)
  • Category (select menu)
  • Tags (tagger)
@extends('maelstrom::layouts.form')

@section('content')

    @component('maelstrom::components.form', [
        'action' => $action,
        'method' => $method,
    ])

        @include('maelstrom::inputs.text', [
            'label' => 'Post Name',
            'name' => 'name',
            'required' => true,
        ])

        @include('maelstrom::inputs.text', [
            'label' => 'URL Slug',
            'name' => 'slug',
            'required' => true,
        ])

        @include('maelstrom::components.media_manager', [
            'label' => 'Featured Image',
            'name' => 'image',
            'required' => true,
        ])

        @include('maelstrom::inputs.switch', [
            'label' => 'Is Sticky?',
            'name' => 'is_sticky',
        ])

        @include('maelstrom::inputs.wysiwyg', [
           'label' => 'Body Content',
           'name' => 'body',
           'required' => true,
       ])

        <div class="flex flex-wrap">
            <div class="w-1/2 pr-10">
            
                @include('maelstrom::inputs.select', [
                    'label' => 'Category',
                    'name' => 'category_id',
                    'options' => [],
                    'required' => true,
                ])
                
            </div>
            <div class="w-1/2">
            
                @include('maelstrom::inputs.tags', [
                    'label' => 'Tags',
                    'name' => 'tags',
                ])
                
            </div>
        </div>

    @endcomponent

@endsection

This should give us a good looking form!

However you'll notice that the Category dropdown is empty. This is because we've not given it any options.

For this example we'll use the Form Options API and edit our config/maelstrom.php to add in our Category::class

So within our form_options area within the config we'll add a new model e.g.

'models' => [
    'categories' => [
        'model' => App\Category::class,
        'value' => 'id',
        'label' => 'name',
    ],
],

Now we can tell our input field to use the form options api instead by adjusting the @include

@include('maelstrom::inputs.select', [
    'label' => 'Category',
    'name' => 'category_id',
    'options' => [],
    'required' => true,
    'remote_uri' => route('maelstrom.form-options', 'categories'),
])

When you refresh the page now, if you inspect the network requests you'll see an API call to fetch the form options for you.

We can now go one step further and allow the user to create real-time categories by adding the create_button property.

@include('maelstrom::inputs.select', [
    'label' => 'Category',
    'name' => 'category_id',
    'options' => [],
    'required' => true,
    'remote_uri' => route('maelstrom.form-options', 'categories'),
    'create_button' => [
        'url' => route('category.create'),
    ],
])

Done!

You should now be able to enter all your data and it should save okay!

However what you might notice is that despite your tags saving into the database column, they are not showing up on the frontend.

This is because we're just JSON encoding them and not saving them against an external table with unique IDs per tag.

If we want to use free roaming tags, we can use the property allow_wild_values to allow it to work without IDs

@include('maelstrom::inputs.tags', [
    'label' => 'Tags',
    'name' => 'tags',
    'allow_wild_values' => true,
])

Save, give the page a refresh and you should now see your tags!

git add .
git commit -m "Form Created"

Adjusting the entry table

Now we've got some data and we know it's saving okay, we can look into tweaking our entry screen with some columns, filters etc.

We'll go back to our Admin\PostController::class and into the index method where we defined our column, we can flesh this out with some other fields.

$this->panel->setTableHeadings([
    [
        'label' => 'Image',
        'type' => 'MediaManagerColumn',
        'name' => 'image',
    ],
    [
        'label' => 'Name',
        'name' => 'name',
        'type' => 'EditLinkColumn'
    ],
    [
        'label' => 'Category',
        'name' => 'category.name',
    ],
    [
        'label' => 'Sticky?',
        'type' => 'BooleanColumn',
        'name' => 'is_sticky',
]);

As we've decided to show the category.name which is on the relationship, we'll need to eager load this so it's available to the view.

We do this by defining it within our __construct e.g.

public function __construct()
{
    $this->panel = maelstrom(Post::class)->setEagerLoad(['category']);
}

We should now see something like the following:

We can make this a bit more useful for the user, by adding in a search, some filters etc.

To start with we can mark the name field as searchable! And whilst we're at it, make the column sortable as well.

[
    'label' => 'Name',
    'name' => 'name',
    'type' => 'EditLinkColumn',
    'searchable' => true,
    'sortable' => true,
],

And now we have a search component with our name field, and the ability to adjust the ordering of our posts by clicking the arrows within the column title.

We can also fix the alignment of the is sticky column by adding the align property.

[
    'label' => 'Sticky?',
    'type' => 'BooleanColumn',
    'name' => 'is_sticky',
    'align' => 'center',
]

Now we can look into creating some filters, to start with we'll setup a sticky filter.

Creating a single select filter (radio buttons)

[
    'label' => 'Sticky?',
    'type' => 'BooleanColumn',
    'name' => 'is_sticky',
    'align' => 'center',
    'filterMultiple' => false,
    'filters' => [
        ['label' => 'Yes', 'value' => 1],
        ['label' => 'No', 'value' => 0],
    ],
],

We've added the filterMultiple and turned it off, as we want a radio button configuration rather than multiple checkboxes, along with the filters array providing the label and value for each of the filters.

Right now the filter wont do anything until we set up some logic to adjust the executed query.

We can do this using the setFilterHandler() method which provides us the applied filters and the query builder.

For demo purposes we'll do this inline within the construct e.g.

public function __construct()
{
    $this->panel = maelstrom(Post::class)
        ->setEagerLoad([
            'category'
        ])
        ->setFilterHandler(function ($filters, $query) {
            if (isset($filters->is_sticky)) {
                $query->where('is_sticky', current($filters->is_sticky));
            }
        });
}

Within the closure of the setFilterHandler we check if the is_sticky filter has been set, and if it has we add an additional constraint to our query via the where method.

As you can have multiple filters, you will get given an array of attached filters, so we use current($filters->is_sticky) to get the first item, you could always use $filters->is_sticky[0] etc...

You should now be able to test your filter and see the results change.

Creating a multiple select filter (checkboxes)

Lastly we'll create another filter for the categories, this will allow multiple to be selected.

To do this, we'll need a list of our categories, you could create a helper to return this data e.g. Category::forFilters() but for this demo, we'll do it inline.

[
    'label' => 'Category',
    'name' => 'category.name',
    'filters' => Category::all()->map(function (Category $category){
        return [
            'label' => $category->name,
            'value' => $category->getKey(),
        ];
    }),
],

Then we'll need to create our filter logic, which is a little more complicated.

->setFilterHandler(function ($filters, $query) {

    // Handles the Sticky filter
    if (isset($filters->is_sticky)) {
        $query->where('is_sticky', current($filters->is_sticky));
    }
    
    // Adds a new condition which shows categories
    // only from the selected ones - must be a nested condition
    // to make it work with any other filters.
    if (isset($filters->{'category.name'})) {
        $query->where(function ($query) use ($filters) {
            foreach ($filters->{'category.name'} as $id) {
                $query->orWhere('category_id', $id);
            }
        });
    }
})

As we want it to work with any other previously applied filters, instead of using where directly, you pass it a closure which will nest the wheres.

Refresh, try out your filters and they should now be working as expected!

Transforming data for the table.

The final thing we'll do before we're done, is display the tags on the entry screen, as this is a JSON object, we'll need to transform the data before it gets to the view otherwise it will look like ["tag x", "tag y"].

You'll often use this technique to manipulate the data before displaying it to the user.

Still within our construct, we can call the setEntriesTransformer method to modify our data before it hits the view.

public function __construct()
{
    $this->panel->setEntriesTransformer(function (Post $post) {
        $post = $post->toArray();
        $post['tags'] = implode(', ', $post['tags']);
    
        return $post;
    });
}

Then we should see our transformed data instead!

The End!

And there we have it, you have a super straight forward to understand backend to handle basic blog posts with categories and tags!

Any questions then please get in contact with talk@maelstrom-cms.com