Laravel 5.1 Beauty - Posts Administration

Finishing out the posts administration area

Posted on May 16, 2015 in L5 Beauty
Note: this is the tenth step in the tutorial.

In this chapter we’ll finish the post functionality in the blog’s administration area. This includes modifying the structure of the posts table with a new migration, pulling in some additional assets, and adding the basic Create, Update, and Delete methods.

Chapter Contents

Modifying the Posts table

The nice thing about database migrations in Laravel 5.1 is that they’re like version control for your database. Right now the version of the posts table still exists from when we created the 10 Minute Blog a few chapters back.

Let’s make some changes to this table.

Requiring Doctrine

In Laravel 5.1 whenever columns in a database need to be modified, the Doctrine package is required. Use composer to install the doctrine/dbal package as instructed below.

Installing doctrine/dbal

vagrant@homestead:~/Code/l5beauty$ composer require "doctrine/dbal"
Using version ^2.5 for doctrine/dbal
./composer.json has been updated
Loading composer repositories with package information
Updating dependencies (including require-dev)
  - Installing doctrine/lexer (v1.0.1)
    Downloading: 100%

  [snip]

Writing lock file
Generating autoload files
Generating optimized class loader

Composer makes adding PHP packages very easy.

Creating the Migration

Create the migration skeleton with the artisan command in the Homestead VM.

Creating restructure_posts_table migration

~/Code/l5beauty$ php artisan make:migration --table=posts \
    restructure_posts_table
Created Migration: 2015_04_28_052603_restructure_posts_table

Then edit the newly created migration to match what’s below.

The Restructure Posts Table Migration

<?php

use Illuminate\Database\Schema\Blueprint;
use Illuminate\Database\Migrations\Migration;

class RestructurePostsTable extends Migration
{
  /**
   * Run the migrations.
   */
  public function up()
  {
    Schema::table('posts', function (Blueprint $table) {
      $table->string('subtitle')->after('title');
      $table->renameColumn('content', 'content_raw');
      $table->text('content_html')->after('content');
      $table->string('page_image')->after('content_html');
      $table->string('meta_description')->after('page_image');
      $table->boolean('is_draft')->after('meta_description');
      $table->string('layout')->after('is_draft')
            ->default('blog.layouts.post');
    });
  }

  /**
   * Reverse the migrations.
   */
  public function down()
  {
    Schema::table('posts', function (Blueprint $table) {
      $table->dropColumn('layout');
      $table->dropColumn('is_draft');
      $table->dropColumn('meta_description');
      $table->dropColumn('page_image');
      $table->dropColumn('content_html');
      $table->renameColumn('content_raw', 'content');
      $table->dropColumn('subtitle');
    });
  }
}

Here’s a quick breakdown of what changed and why.

subtitle
Our final blog will also require a subtitle
content_html and content_raw
We’ll do our editing in Markdown, but whenever content is saved we also want to save the HTML version.
page_image
Like the tags table, each post will allow a unique header image at the top.
meta_description
To populate the META tags on the post’s page for search engines.
is_draft
We’ll add a flag on each post so as to not inadvertently publish a draft post.
layout
This will give us the ability to use unique layouts on the posts.

Running Migrations

Now that the migration is created, run migrations from the Homestead VM.

Running Migrations

vagrant@homestead:~/Code/l5beauty$ php artisan migrate
Migrated: 2015_04_28_052603_restructure_posts_table

Now the work on the database is complete!

Updating the Models

Let’s update the Post model and the Tag model to establish the relationships between the two.

Update the app\Tag.php file with the content below.

The new Tag model

<?php
namespace App;

use Illuminate\Database\Eloquent\Model;

class Tag extends Model
{
  protected $fillable = [
    'tag', 'title', 'subtitle', 'page_image', 'meta_description',
    'reverse_direction',
  ];

  /**
   * The many-to-many relationship between tags and posts.
   *
   * @return BelongsToMany
   */
  public function posts()
  {
    return $this->belongsToMany('App\Post', 'post_tag_pivot');
  }

  /**
   * Add any tags needed from the list
   *
   * @param array $tags List of tags to check/add
   */
  public static function addNeededTags(array $tags)
  {
    if (count($tags) === 0) {
      return;
    }

    $found = static::whereIn('tag', $tags)->lists('tag')->all();

    foreach (array_diff($tags, $found) as $tag) {
      static::create([
        'tag' => $tag,
        'title' => $tag,
        'subtitle' => 'Subtitle for '.$tag,
        'page_image' => '',
        'meta_description' => '',
        'reverse_direction' => false,
      ]);
    }
  }
}

$fillable
Here we set the name of the columns that can be filled with an array. The addNeededTags() method will use this.
posts()
The many-to-many relationship between posts and tags.
addNeededTags()
A static function to add tags that aren’t in the database already.

In the posts() method we’re only passing two arguments to belongsToMany(). The first argument is the name of the model class. The second argument is the name of the table to use. The next two arguments are the foreignKey and the otherKey, but since we’re using post_id and tag_id, the additional arguments to belongsToMany() can be omitted. (Laravel is smart enough to figure them out.)

Mass Assignment Protection

Laravel 5.1’s models are built with Mass Assignment Protection. This means the model’s create() method takes an array of column names and values, but only allows assignment of those columns that are in a white list. (The white list is the $fillable attribute).

Now update the app\Post.php file with the content below.

The new Post model

<?php
namespace App;

use App\Services\Markdowner;
use Illuminate\Database\Eloquent\Model;

class Post extends Model
{
    protected $dates = ['published_at'];

    /**
     * The many-to-many relationship between posts and tags.
     *
     * @return BelongsToMany
     */
    public function tags()
    {
        return $this->belongsToMany('App\Tag', 'post_tag_pivot');
    }

    /**
     * Set the title attribute and automatically the slug
     *
     * @param string $value
     */
    public function setTitleAttribute($value)
    {
        $this->attributes['title'] = $value;

        if (! $this->exists) {
            $this->setUniqueSlug($value, '');
        }
    }

    /**
     * Recursive routine to set a unique slug
     *
     * @param string $title
     * @param mixed $extra
     */
    protected function setUniqueSlug($title, $extra)
    {
        $slug = str_slug($title.'-'.$extra);

        if (static::whereSlug($slug)->exists()) {
            $this->setUniqueSlug($title, $extra + 1);
            return;
        }

        $this->attributes['slug'] = $slug;
    }

    /**
     * Set the HTML content automatically when the raw content is set
     *
     * @param string $value
     */
    public function setContentRawAttribute($value)
    {
        $markdown = new Markdowner();

        $this->attributes['content_raw'] = $value;
        $this->attributes['content_html'] = $markdown->toHTML($value);
    }

    /**
     * Sync tag relation adding new tags as needed
     *
     * @param array $tags
     */
    public function syncTags(array $tags)
    {
      Tag::addNeededTags($tags);

      if (count($tags)) {
        $this->tags()->sync(
          Tag::whereIn('tag', $tags)->lists('id')->all()
        );
        return;
      }

      $this->tags()->detach();
    }
}

tags()
Similar to the posts() method in the Tag model, but here we’re going the other way.
setTitleAttribute()
We changed this from the previous version to call the setUniqueSlug() method.
setUniqueSlug()
A function that recurses to set the unique slug when needed.
setContentRawAttribute()
Now when the content_raw value is set in the model, it will be automatically be converted to HTML and assigned to the content_html attribute.
syncTags()
Synchronizes the tags with the post.

Adding Selectize.js and Pickadate.js

Let’s add two additional assets to our system. We’ll use bower to pull the resources in and gulp to put them where we want them.

Pulling them in with Bower

The first is Selectize.js. This library is handy for setting up tagging and we’ll use to to assign zero or more Tags to each Post. Follow the instructions below to pull it in with bower. (I’m doing this from the Host OS, but you should also be able to do this within Homestead VM.)

Adding Selectize.js with Bower

~/Code/l5beauty% bower install selectize --save
bower not-cached    git://github.com/brianreavis/selectize.js.git#*
bower resolve       git://github.com/brianreavis/selectize.js.git#*

[snip]

selectize#0.12.1 vendor/bower_dl/selectize
├── jquery#2.1.4
├── microplugin#0.0.3
└── sifter#0.4.1

Then let’s pull in Pickadate.js. There’s quite a few libraries for picking dates and times available, but I wanted to use this one because it works slick on small devices. Follow the instructions below to pull in Pickadate.js.

Adding Pickadate.js with Bower

~/Code/l5beauty% bower install pickadate --save
bower not-cached    git://github.com/amsul/pickadate.js.git#*
bower resolve       git://github.com/amsul/pickadate.js.git#*
bower download      https://github.com/amsul/pickadate.js/archive/3.5.6.tar.gz
bower extract       pickadate#* archive.tar.gz
bower resolved      git://github.com/amsul/pickadate.js.git#3.5.6
bower install       pickadate#3.5.6

pickadate#3.5.6 vendor/bower_dl/pickadate
└── jquery#2.1.4

Managing them with Gulp

Now that these libraries are downloaded, update the gulpfile.js to matche what’s below.

Updated gulpfile.js

var gulp = require('gulp');
var rename = require('gulp-rename');
var elixir = require('laravel-elixir');

/**
 * Copy any needed files.
 *
 * Do a 'gulp copyfiles' after bower updates
 */
gulp.task("copyfiles", function() {

  // Copy jQuery, Bootstrap, and FontAwesome
  gulp.src("vendor/bower_dl/jquery/dist/jquery.js")
      .pipe(gulp.dest("resources/assets/js/"));

  gulp.src("vendor/bower_dl/bootstrap/less/**")
      .pipe(gulp.dest("resources/assets/less/bootstrap"));

  gulp.src("vendor/bower_dl/bootstrap/dist/js/bootstrap.js")
      .pipe(gulp.dest("resources/assets/js/"));

  gulp.src("vendor/bower_dl/bootstrap/dist/fonts/**")
      .pipe(gulp.dest("public/assets/fonts"));

  gulp.src("vendor/bower_dl/fontawesome/less/**")
      .pipe(gulp.dest("resources/assets/less/fontawesome"));

  gulp.src("vendor/bower_dl/fontawesome/fonts/**")
      .pipe(gulp.dest("public/assets/fonts"));

  // Copy datatables
  var dtDir = 'vendor/bower_dl/datatables-plugins/integration/';

  gulp.src("vendor/bower_dl/datatables/media/js/jquery.dataTables.js")
      .pipe(gulp.dest('resources/assets/js/'));

  gulp.src(dtDir + 'bootstrap/3/dataTables.bootstrap.css')
      .pipe(rename('dataTables.bootstrap.less'))
      .pipe(gulp.dest('resources/assets/less/others/'));

  gulp.src(dtDir + 'bootstrap/3/dataTables.bootstrap.js')
      .pipe(gulp.dest('resources/assets/js/'));

  // Copy selectize
  gulp.src("vendor/bower_dl/selectize/dist/css/**")
      .pipe(gulp.dest("public/assets/selectize/css"));

  gulp.src("vendor/bower_dl/selectize/dist/js/standalone/selectize.min.js")
      .pipe(gulp.dest("public/assets/selectize/"));

  // Copy pickadate
  gulp.src("vendor/bower_dl/pickadate/lib/compressed/themes/**")
      .pipe(gulp.dest("public/assets/pickadate/themes/"));

  gulp.src("vendor/bower_dl/pickadate/lib/compressed/picker.js")
      .pipe(gulp.dest("public/assets/pickadate/"));

  gulp.src("vendor/bower_dl/pickadate/lib/compressed/picker.date.js")
      .pipe(gulp.dest("public/assets/pickadate/"));

  gulp.src("vendor/bower_dl/pickadate/lib/compressed/picker.time.js")
      .pipe(gulp.dest("public/assets/pickadate/"));

});

/**
 * Default gulp is to run this elixir stuff
 */
elixir(function(mix) {

  // Combine scripts
  mix.scripts([
      'js/jquery.js',
      'js/bootstrap.js',
      'js/jquery.dataTables.js',
      'js/dataTables.bootstrap.js'
    ],
    'public/assets/js/admin.js',
    'resources/assets'
);

  // Compile Less
  mix.less('admin.less', 'public/assets/css/admin.css');
});

This configuration is basically the same as before, but with the needed files from selectize and pickadate copied into the public asset directory.

Run gulp copyfiles to do the copying.

Running gulp copyfiles

~/Code/l5beauty% gulp copyfiles
gulp copyfiles
[21:10:15] Using gulpfile ~/Projects/l5beauty/gulpfile.js
[21:10:15] Starting 'copyfiles'...
[21:10:15] Finished 'copyfiles' after 48 ms

Creating the Request Classes

Just like we did in Chapter 10 with the Tags, we’ll use Request classes to validate the create and update Post requests.

First create the requests using artisan in the Homestead VM. This will create skeletons for each of these classes in the app/Http/Requests directory.

Creating the Request Class Skeletons

~/Code/l5beauty$ php artisan make:request PostCreateRequest
Request created successfully.
~/Code/l5beauty$ php artisan make:request PostUpdateRequest
Request created successfully.

Update the newly created PostCreateRequest.php to match what’s below.

Content of PostCreateRequest.php

<?php
namespace App\Http\Requests;

use Carbon\Carbon;

class PostCreateRequest extends Request
{
  /**
   * Determine if the user is authorized to make this request.
   */
  public function authorize()
  {
    return true;
  }

  /**
   * Get the validation rules that apply to the request.
   *
   * @return array
   */
  public function rules()
  {
    return [
      'title' => 'required',
      'subtitle' => 'required',
      'content' => 'required',
      'publish_date' => 'required',
      'publish_time' => 'required',
      'layout' => 'required',
    ];
  }

  /**
   * Return the fields and values to create a new post from
   */
  public function postFillData()
  {
    $published_at = new Carbon(
      $this->publish_date.' '.$this->publish_time
    );
    return [
      'title' => $this->title,
      'subtitle' => $this->subtitle,
      'page_image' => $this->page_image,
      'content_raw' => $this->get('content'),
      'meta_description' => $this->meta_description,
      'is_draft' => (bool)$this->is_draft,
      'published_at' => $published_at,
      'layout' => $this->layout,
    ];
  }
}

This is a standard request with the authorize() and rules() methods, but we’re also adding the postFillData() to make it easy to pull all the data from the request to fill a new Post model with.

And update the PostUpdateRequest.php to match the following:

Content of PostUpdateRequest.php

<?php
namespace App\Http\Requests;

class PostUpdateRequest extends PostCreateRequest
{
  //
}

NOTE: We’re just inherting the authorize() and rules() methods from the PostCreateRequest class. Yes, we could get by with a single class to handle both but I don’t know how things may change in the future and like the idea of separate classes.

Creating the PostFormFields Job

Let’s create a utility job we can call from the PostController. It’ll be called the PostFormFields job. This job will get executed when we want to get a list of all the fields to populate post form.

Laravel Job Classes are Useful

Whenever you want to encapsulate a bit of action into its own class, a job class is one way to go. You dispatch to the job and don’t have to worry about the details. You can even queue jobs to occur later.

Just like helpers.php contain one-off functions, I like to think of job classes as one-off action classes.

First create the job skeleton with artisan.

Creating PostFormFields Job Skeleton

~/Code/l5beauty$ php artisan make:job PostFormFields
Command created successfully.

This creates the file in app/Jobs.

Edit the PostFormFields job to match the following.

Content of PostFormFields job

<?php

namespace App\Jobs;

use App\Post;
use App\Tag;
use Carbon\Carbon;
use Illuminate\Contracts\Bus\SelfHandling;

class PostFormFields extends Job implements SelfHandling
{
  /**
   * The id (if any) of the Post row
   *
   * @var integer
   */
  protected $id;

  /**
   * List of fields and default value for each field
   *
   * @var array
   */
  protected $fieldList = [
    'title' => '',
    'subtitle' => '',
    'page_image' => '',
    'content' => '',
    'meta_description' => '',
    'is_draft' => "0",
    'publish_date' => '',
    'publish_time' => '',
    'layout' => 'blog.layouts.post',
    'tags' => [],
  ];

  /**
   * Create a new command instance.
   *
   * @param integer $id
   */
  public function __construct($id = null)
  {
    $this->id = $id;
  }

  /**
   * Execute the command.
   *
   * @return array of fieldnames => values
   */
  public function handle()
  {
    $fields = $this->fieldList;

    if ($this->id) {
      $fields = $this->fieldsFromModel($this->id, $fields);
    } else {
      $when = Carbon::now()->addHour();
      $fields['publish_date'] = $when->format('M-j-Y');
      $fields['publish_time'] = $when->format('g:i A');
    }

    foreach ($fields as $fieldName => $fieldValue) {
      $fields[$fieldName] = old($fieldName, $fieldValue);
    }

    return array_merge(
      $fields,
      ['allTags' => Tag::lists('tag')->all()]
    );
  }

  /**
   * Return the field values from the model
   *
   * @param integer $id
   * @param array $fields
   * @return array
   */
  protected function fieldsFromModel($id, array $fields)
  {
    $post = Post::findOrFail($id);

    $fieldNames = array_keys(array_except($fields, ['tags']));

    $fields = ['id' => $id];
    foreach ($fieldNames as $field) {
      $fields[$field] = $post->{$field};
    }

    $fields['tags'] = $post->tags()->lists('tag')->all();

    return $fields;
  }
}

The point of this job is to return an array of fields and values to use to populate a form.

If a Post isn’t loaded (as will be the case in a create), then default values will be returned. If a Post is loaded (for updates), then the values will be pulled from the database.

Also, there’s two extra fields returned, tags and allTags.

  1. tags - an array of all the tags associated with the Post.
  2. allTags - an array of all tags on file

Adding to helpers.php

We’ll need a couple one-off functions so edit the app/helpers.php file and add the two functions below.

Additions to helpers.php

/**
 * Return "checked" if true
 */
function checked($value)
{
    return $value ? 'checked' : '';
}

/**
 * Return img url for headers
 */
function page_image($value = null)
{
    if (empty($value)) {
        $value = config('blog.page_image');
    }
    if (! starts_with($value, 'http') && $value[0] !== '/') {
        $value = config('blog.uploads.webpath') . '/' . $value;
    }

    return $value;
}
checked()
This helper function will be used in views to output the checked attribute in check boxes and radio buttons.
page_image()
This function returns the full path to an image in the uploaded area using the value from the configuration. If a value isn’t specified then it pulls a default image from the blog config (which you’ll need to set up yourself in as ‘page_image’ if you wish to use.)

Updating the Post Model

You may have noticed how we’re breaking apart the published_at into publish_date and publish_time. Let’s add a couple fields to the Post model to make this easy. Update app/Post.php as specified below.

Updates to Post Model

<?php
// Add the following near the top of the class, after $dates
  protected $fillable = [
    'title', 'subtitle', 'content_raw', 'page_image', 'meta_description',
    'layout', 'is_draft', 'published_at',
  ];

// Add the following three methods

  /**
   * Return the date portion of published_at
   */
  public function getPublishDateAttribute($value)
  {
    return $this->published_at->format('M-j-Y');
  }

  /**
   * Return the time portion of published_at
   */
  public function getPublishTimeAttribute($value)
  {
    return $this->published_at->format('g:i A');
  }

  /**
   * Alias for content_raw
   */
  public function getContentAttribute($value)
  {
    return $this->content_raw;
  }

The $fillable property will let us fill the data during creating.

We also added getContentAttribute() as an accessor that returns $this->content_raw. Now when you use $post->content it’ll execute this function.

Updating the Controller

Now we’ll update all needed functionality in the PostController class. (Remember the file for this class is in the app/Http/Controllers/Admin directory.)

Because of the Request classes and PostFormFields class created earlier, the size of the controller will stay relatively small.

Update the PostController class to match what’s below.

Content of PostController.php

<?php

namespace App\Http\Controllers\Admin;

use App\Jobs\PostFormFields;
use App\Http\Requests;
use App\Http\Requests\PostCreateRequest;
use App\Http\Requests\PostUpdateRequest;
use App\Http\Controllers\Controller;
use App\Post;

class PostController extends Controller
{
  /**
   * Display a listing of the posts.
   */
  public function index()
  {
    return view('admin.post.index')
      ->withPosts(Post::all());
  }

  /**
   * Show the new post form
   */
  public function create()
  {
    $data = $this->dispatch(new PostFormFields());

    return view('admin.post.create', $data);
  }

  /**
   * Store a newly created Post
   *
   * @param PostCreateRequest $request
   */
  public function store(PostCreateRequest $request)
  {
    $post = Post::create($request->postFillData());
    $post->syncTags($request->get('tags', []));

    return redirect()
        ->route('admin.post.index')
        ->withSuccess('New Post Successfully Created.');
  }

  /**
   * Show the post edit form
   *
   * @param  int  $id
   * @return Response
   */
  public function edit($id)
  {
    $data = $this->dispatch(new PostFormFields($id));

    return view('admin.post.edit', $data);
  }

  /**
   * Update the Post
   *
   * @param PostUpdateRequest $request
   * @param int  $id
   */
  public function update(PostUpdateRequest $request, $id)
  {
    $post = Post::findOrFail($id);
    $post->fill($request->postFillData());
    $post->save();
    $post->syncTags($request->get('tags', []));

    if ($request->action === 'continue') {
      return redirect()
          ->back()
          ->withSuccess('Post saved.');
    }

    return redirect()
        ->route('admin.post.index')
        ->withSuccess('Post saved.');
  }

  /**
   * Remove the specified resource from storage.
   *
   * @param  int  $id
   * @return Response
   */
  public function destroy($id)
  {
    $post = Post::findOrFail($id);
    $post->tags()->detach();
    $post->delete();

    return redirect()
        ->route('admin.post.index')
        ->withSuccess('Post deleted.');
  }
}

index()
Pass index the view $posts will all the posts and file and return it.
create()
Use the PostFormFields job to return all the field values. Return the create view with these values passed in.
store()
Create the post with the fillable data from the request. Attach any tags and return to the index route with a success message.
edit()
Use the PostFormFields job to return all the field values for the post being edited. Return the edit view with these values passed in.
update()
Load the post. Update all the fillable fields. Save any changes and keep the tags in sync. Then return either back to the edit form or to the index list with a success message.
destroy()
Load the post. Unlink any associated tags. Delete the post and return to the index route with a success message.

A pretty slim controller really. About the only thing left to do are the views.

The Post Views

Now we’ll create all the views the PostController referenced earlier.

First update the existing index.blade.php in the resources/views/admin/post directory to match what’s below.

Content of admin.post.index view

@extends('admin.layout')

@section('content')
  <div class="container-fluid">
    <div class="row page-title-row">
      <div class="col-md-6">
        <h3>Posts <small>» Listing</small></h3>
      </div>
      <div class="col-md-6 text-right">
        <a href="/admin/post/create" class="btn btn-success btn-md">
          <i class="fa fa-plus-circle"></i> New Post
        </a>
      </div>
    </div>

    <div class="row">
      <div class="col-sm-12">

        @include('admin.partials.errors')
        @include('admin.partials.success')

        <table id="posts-table" class="table table-striped table-bordered">
          <thead>
            <tr>
              <th>Published</th>
              <th>Title</th>
              <th>Subtitle</th>
              <th data-sortable="false">Actions</th>
            </tr>
          </thead>
          <tbody>
            @foreach ($posts as $post)
              <tr>
                <td data-order="{{ $post->published_at->timestamp }}">
                  {{ $post->published_at->format('j-M-y g:ia') }}
                </td>
                <td>{{ $post->title }}</td>
                <td>{{ $post->subtitle }}</td>
                <td>
                  <a href="/admin/post/{{ $post->id }}/edit"
                     class="btn btn-xs btn-info">
                    <i class="fa fa-edit"></i> Edit
                  </a>
                  <a href="/blog/{{ $post->slug }}"
                     class="btn btn-xs btn-warning">
                    <i class="fa fa-eye"></i> View
                  </a>
                </td>
              </tr>
            @endforeach
          </tbody>
        </table>
      </div>
    </div>

  </div>
@stop

@section('scripts')
  <script>
    $(function() {
      $("#posts-table").DataTable({
        order: [[0, "desc"]]
      });
    });
  </script>
@stop

This is a pretty simple view that sets up the table with all the posts and then initializes the table as a DataTable in the scripts section.

Next, in the resources/views/admin/post directory create a create.blade.php file with the following content.

Content of admin.post.create view

@extends('admin.layout')

@section('styles')
  <link href="/assets/pickadate/themes/default.css" rel="stylesheet">
  <link href="/assets/pickadate/themes/default.date.css" rel="stylesheet">
  <link href="/assets/pickadate/themes/default.time.css" rel="stylesheet">
  <link href="/assets/selectize/css/selectize.css" rel="stylesheet">
  <link href="/assets/selectize/css/selectize.bootstrap3.css" rel="stylesheet">
@stop

@section('content')
  <div class="container-fluid">
    <div class="row page-title-row">
      <div class="col-md-12">
        <h3>Posts <small>» Add New Post</small></h3>
      </div>
    </div>

    <div class="row">
      <div class="col-sm-12">
        <div class="panel panel-default">
          <div class="panel-heading">
            <h3 class="panel-title">New Post Form</h3>
          </div>
          <div class="panel-body">

            @include('admin.partials.errors')

            <form class="form-horizontal" role="form" method="POST"
                  action="{{ route('admin.post.store') }}">
              <input type="hidden" name="_token" value="{{ csrf_token() }}">

              @include('admin.post._form')

              <div class="col-md-8">
                <div class="form-group">
                  <div class="col-md-10 col-md-offset-2">
                    <button type="submit" class="btn btn-primary btn-lg">
                      <i class="fa fa-disk-o"></i>
                      Save New Post
                    </button>
                  </div>
                </div>
              </div>

            </form>

          </div>
        </div>
      </div>
    </div>
  </div>

@stop

@section('scripts')
  <script src="/assets/pickadate/picker.js"></script>
  <script src="/assets/pickadate/picker.date.js"></script>
  <script src="/assets/pickadate/picker.time.js"></script>
  <script src="/assets/selectize/selectize.min.js"></script>
  <script>
    $(function() {
      $("#publish_date").pickadate({
        format: "mmm-d-yyyy"
      });
      $("#publish_time").pickatime({
        format: "h:i A"
      });
      $("#tags").selectize({
        create: true
      });
    });
  </script>
@stop

Here we add in the Selectize and Pickadate libraries. You’ll also note that we’re referencing an admin.post._form partial which isn’t yet created.

Let’s create that partial. In the resources/views/admin/post directory create the _form.blade.php file with the following content.

Content of the admin.post._form partial

<div class="row">
  <div class="col-md-8">
    <div class="form-group">
      <label for="title" class="col-md-2 control-label">
        Title
      </label>
      <div class="col-md-10">
        <input type="text" class="form-control" name="title" autofocus
               id="title" value="{{ $title }}">
      </div>
    </div>
    <div class="form-group">
      <label for="subtitle" class="col-md-2 control-label">
        Subtitle
      </label>
      <div class="col-md-10">
        <input type="text" class="form-control" name="subtitle"
               id="subtitle" value="{{ $subtitle }}">
      </div>
    </div>
    <div class="form-group">
      <label for="page_image" class="col-md-2 control-label">
        Page Image
      </label>
      <div class="col-md-10">
        <div class="row">
          <div class="col-md-8">
            <input type="text" class="form-control" name="page_image"
                   id="page_image" onchange="handle_image_change()"
                   alt="Image thumbnail" value="{{ $page_image }}">
          </div>
          <script>
            function handle_image_change() {
              $("#page-image-preview").attr("src", function () {
                var value = $("#page_image").val();
                if ( ! value) {
                  value = {!! json_encode(config('blog.page_image')) !!};
                  if (value == null) {
                    value = '';
                  }
                }
                if (value.substr(0, 4) != 'http' &&
                    value.substr(0, 1) != '/') {
                  value = {!! json_encode(config('blog.uploads.webpath')) !!}
                        + '/' + value;
                }
                return value;
              });
            }
          </script>
          <div class="visible-sm space-10"></div>
          <div class="col-md-4 text-right">
            <img src="{{ page_image($page_image) }}" class="img img_responsive"
                 id="page-image-preview" style="max-height:40px">
          </div>
        </div>
      </div>
    </div>
    <div class="form-group">
      <label for="content" class="col-md-2 control-label">
        Content
      </label>
      <div class="col-md-10">
        <textarea class="form-control" name="content" rows="14"
                  id="content">{{ $content }}</textarea>
      </div>
    </div>
  </div>
  <div class="col-md-4">
    <div class="form-group">
      <label for="publish_date" class="col-md-3 control-label">
        Pub Date
      </label>
      <div class="col-md-8">
        <input class="form-control" name="publish_date" id="publish_date"
               type="text" value="{{ $publish_date }}">
      </div>
    </div>
    <div class="form-group">
      <label for="publish_time" class="col-md-3 control-label">
        Pub Time
      </label>
      <div class="col-md-8">
        <input class="form-control" name="publish_time" id="publish_time"
               type="text" value="{{ $publish_time }}">
      </div>
    </div>
    <div class="form-group">
      <div class="col-md-8 col-md-offset-3">
        <div class="checkbox">
          <label>
            <input {{ checked($is_draft) }} type="checkbox" name="is_draft">
            Draft?
          </label>
        </div>
      </div>
    </div>
    <div class="form-group">
      <label for="tags" class="col-md-3 control-label">
        Tags
      </label>
      <div class="col-md-8">
        <select name="tags[]" id="tags" class="form-control" multiple>
          @foreach ($allTags as $tag)
            <option @if (in_array($tag, $tags)) selected @endif
            value="{{ $tag }}">
              {{ $tag }}
            </option>
          @endforeach
        </select>
      </div>
    </div>
    <div class="form-group">
      <label for="layout" class="col-md-3 control-label">
        Layout
      </label>
      <div class="col-md-8">
        <input type="text" class="form-control" name="layout"
               id="layout" value="{{ $layout }}">
      </div>
    </div>
    <div class="form-group">
      <label for="meta_description" class="col-md-3 control-label">
        Meta
      </label>
      <div class="col-md-8">
        <textarea class="form-control" name="meta_description"
                  id="meta_description"
                  rows="6">{{ $meta_description }}</textarea>
      </div>
    </div>

  </div>
</div>

This is a partial because both the create and edit view will share it.

Create edit.blade.php in the same directory with the content below.

Content of the admin.post.edit view

@extends('admin.layout')

@section('styles')
  <link href="/assets/pickadate/themes/default.css" rel="stylesheet">
  <link href="/assets/pickadate/themes/default.date.css" rel="stylesheet">
  <link href="/assets/pickadate/themes/default.time.css" rel="stylesheet">
  <link href="/assets/selectize/css/selectize.css" rel="stylesheet">
  <link href="/assets/selectize/css/selectize.bootstrap3.css" rel="stylesheet">
@stop

@section('content')
  <div class="container-fluid">
    <div class="row page-title-row">
      <div class="col-md-12">
        <h3>Posts <small>» Edit Post</small></h3>
      </div>
    </div>

    <div class="row">
      <div class="col-sm-12">
        <div class="panel panel-default">
          <div class="panel-heading">
            <h3 class="panel-title">Post Edit Form</h3>
          </div>
          <div class="panel-body">

            @include('admin.partials.errors')
            @include('admin.partials.success')

            <form class="form-horizontal" role="form" method="POST"
                  action="{{ route('admin.post.update', $id) }}">
              <input type="hidden" name="_token" value="{{ csrf_token() }}">
              <input type="hidden" name="_method" value="PUT">

              @include('admin.post._form')

              <div class="col-md-8">
                <div class="form-group">
                  <div class="col-md-10 col-md-offset-2">
                    <button type="submit" class="btn btn-primary btn-lg"
                            name="action" value="continue">
                      <i class="fa fa-floppy-o"></i>
                      Save - Continue
                    </button>
                    <button type="submit" class="btn btn-success btn-lg"
                            name="action" value="finished">
                      <i class="fa fa-floppy-o"></i>
                      Save - Finished
                    </button>
                    <button type="button" class="btn btn-danger btn-lg"
                            data-toggle="modal" data-target="#modal-delete">
                      <i class="fa fa-times-circle"></i>
                      Delete
                    </button>
                  </div>
                </div>
              </div>

            </form>

          </div>
        </div>
      </div>
    </div>

    {{-- Confirm Delete --}}
    <div class="modal fade" id="modal-delete" tabIndex="-1">
      <div class="modal-dialog">
        <div class="modal-content">
          <div class="modal-header">
            <button type="button" class="close" data-dismiss="modal">
              ×
            </button>
            <h4 class="modal-title">Please Confirm</h4>
          </div>
          <div class="modal-body">
            <p class="lead">
              <i class="fa fa-question-circle fa-lg"></i>  
              Are you sure you want to delete this post?
            </p>
          </div>
          <div class="modal-footer">
            <form method="POST" action="{{ route('admin.post.destroy', $id) }}">
              <input type="hidden" name="_token" value="{{ csrf_token() }}">
              <input type="hidden" name="_method" value="DELETE">
              <button type="button" class="btn btn-default"
                      data-dismiss="modal">Close</button>
              <button type="submit" class="btn btn-danger">
                <i class="fa fa-times-circle"></i> Yes
              </button>
            </form>
          </div>
        </div>
      </div>
    </div>
  </div>

@stop

@section('scripts')
  <script src="/assets/pickadate/picker.js"></script>
  <script src="/assets/pickadate/picker.date.js"></script>
  <script src="/assets/pickadate/picker.time.js"></script>
  <script src="/assets/selectize/selectize.min.js"></script>
  <script>
    $(function() {
      $("#publish_date").pickadate({
        format: "mmm-d-yyyy"
      });
      $("#publish_time").pickatime({
        format: "h:i A"
      });
      $("#tags").selectize({
        create: true
      });
    });
  </script>
@stop

And with that, all the views for the posts management are done.

Removing the show route

The last thing to do before the administration area is complete is to remove the post show route.

Edit app/Http/routes.php, editing it as instructed below.

Removing the admin.post.show route

// Find the following line
  resource('admin/post', 'PostController');
// Change it to this
  resource('admin/post', 'PostController', ['except' => 'show']);

Congratulations!

The administration area of your blog is complete. Try it out. Add a few posts, edit them. Try deleting them.

The display of the blog pages doesn’t look beautiful yet, we’ll get to that next.

Recap

This was a fairly long chapter but a huge amount was accomplished. We created a migration to modify the posts table and then updated the Tag and Post models. Then bower was used to pull in the Selectize.js and Pickadate.js libraries which, of course, we managed using gulp.

Request classes (to handle form input) were created. As was a Laravel Job class to return the post form data. Finally, the controller and views were wrapped up.

All this work resulted in a clean and easy way to administer posts.

Next we’ll display the posts to the user.

comments powered by Disqus