Ref: https://pusher.com/tutorials/cms-laravel-vue-part-1
Install Laravel Installer globally (It’d be better than ‘create project’ and download from the laravel source):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34
|
teddy@teddy:~/Documents/works/laravel$ composer global require "laravel/installer" Changed current directory to /home/teddy/.composer ./composer.json is not writable. teddy@teddy:~/Documents/works/laravel$ sudo composer global require "laravel/installer" [sudo] password for teddy: Changed current directory to /home/teddy/.composer Do not run Composer as root/super user! See https://getcomposer.org/root for details Using version ^2.0 for laravel/installer ./composer.json has been updated Loading composer repositories with package information Updating dependencies (including require-dev) Nothing to install or update Generating autoload files teddy@teddy:~/Documents/works/laravel$ echo 'export PATH="$HOME/.composer/vendor/bin:$PATH"' >> ~/.bashrc teddy@teddy:~/Documents/works/laravel$ source ~/.bashrc teddy@teddy:~/Documents/works/laravel$ laravel Laravel Installer 2.0.1 Usage: command [options] [arguments] Options: -h, --help Display this help message -q, --quiet Do not output any message -V, --version Display this application version --ansi Force ANSI output --no-ansi Disable ANSI output -n, --no-interaction Do not ask any interactive question -v|vv|vvv, --verbose Increase the verbosity of messages: 1 for normal output, 2 for more verbose output and 3 for debug Available commands: help Displays help for a command list Lists commands new Create a new Laravel application. |
Create the Laravel CMS Project ‘laravel-cms’:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172
|
teddy@teddy:~/Documents/works/laravel$ laravel new laravel-cms Crafting application... Loading composer repositories with package information Installing dependencies (including require-dev) from lock file Package operations: 86 installs, 0 updates, 0 removals - Installing doctrine/inflector (v1.3.0): Loading from cache - Installing doctrine/lexer (v1.0.1): Loading from cache - Installing dragonmantank/cron-expression (v2.2.0): Loading from cache - Installing erusev/parsedown (1.7.1): Loading from cache - Installing vlucas/phpdotenv (v2.5.2): Downloading (100%) - Installing symfony/css-selector (v4.2.2): Downloading (100%) - Installing tijsverkoyen/css-to-inline-styles (2.2.1): Loading from cache - Installing symfony/polyfill-php72 (v1.10.0): Downloading (100%) - Installing symfony/polyfill-mbstring (v1.10.0): Loading from cache - Installing symfony/var-dumper (v4.2.2): Downloading (100%) - Installing symfony/routing (v4.2.2): Downloading (100%) - Installing symfony/process (v4.2.2): Downloading (100%) - Installing symfony/polyfill-ctype (v1.10.0): Loading from cache - Installing symfony/http-foundation (v4.2.2): Downloading (100%) - Installing symfony/contracts (v1.0.2): Downloading (100%) - Installing symfony/event-dispatcher (v4.2.2): Downloading (100%) - Installing psr/log (1.1.0): Loading from cache - Installing symfony/debug (v4.2.2): Downloading (100%) - Installing symfony/http-kernel (v4.2.2): Downloading (100%) - Installing symfony/finder (v4.2.2): Downloading (100%) - Installing symfony/console (v4.2.2): Downloading (100%) - Installing egulias/email-validator (2.1.7): Downloading (100%) - Installing swiftmailer/swiftmailer (v6.1.3): Loading from cache - Installing paragonie/random_compat (v9.99.99): Loading from cache - Installing ramsey/uuid (3.8.0): Loading from cache - Installing psr/simple-cache (1.0.1): Loading from cache - Installing psr/container (1.0.0): Loading from cache - Installing opis/closure (3.1.5): Downloading (100%) - Installing symfony/translation (v4.2.2): Downloading (100%) - Installing nesbot/carbon (1.36.2): Loading from cache - Installing monolog/monolog (1.24.0): Loading from cache - Installing league/flysystem (1.0.49): Loading from cache - Installing ralouphie/getallheaders (2.0.5): Loading from cache - Installing psr/http-message (1.0.1): Loading from cache - Installing guzzlehttp/psr7 (1.5.2): Loading from cache - Installing guzzlehttp/promises (v1.3.1): Loading from cache - Installing guzzlehttp/guzzle (6.3.3): Loading from cache - Installing laravel/slack-notification-channel (v1.0.3): Downloading (connectDownloading (100%) - Installing zendframework/zend-diactoros (1.8.6):Downloading (100%) ) - Installing php-http/promise (v1.0.0): Downloading (100%) - Installing php-http/httplug (v1.1.0): Downloading (100%) - Installing php-http/guzzle6-adapter (v1.1.1): Downloading (100%) - Installing lcobucci/jwt (3.2.5): Downloading (100%) - Installing nexmo/client (1.6.0): Downloading (100%) - Installing laravel/nexmo-notification-channel (v1.0.1): Downloading (connectDownloading (100%) - Installing laravel/framework (v5.7.21): Downloading (100%) - Installing fideloper/proxy (4.1.0): Downloading (100%) - Installing jakub-onderka/php-console-color (v0.2): Loading from cache - Installing nikic/php-parser (v4.2.0): Downloading (100%) - Installing jakub-onderka/php-console-highlighter (v0.4): Downloading (connecDownloading (100%) - Installing dnoegel/php-xdg-base-dir (0.1): Loading from cache - Installing psy/psysh (v0.9.9): Downloading (100%) - Installing laravel/tinker (v1.0.8): Downloading (100%) - Installing beyondcode/laravel-dump-server (1.2.2): Downloading (connecting..Downloading (100%) - Installing fzaninotto/faker (v1.8.0): Loading from cache - Installing hamcrest/hamcrest-php (v2.0.0): Loading from cache - Installing mockery/mockery (1.2.0): Loading from cache - Installing filp/whoops (2.3.1): Downloading (100%) - Installing nunomaduro/collision (v2.1.1): Downloading (100%) - Installing webmozart/assert (1.4.0): Loading from cache - Installing phpdocumentor/reflection-common (1.0.1): Loading from cache - Installing phpdocumentor/type-resolver (0.4.0): Loading from cache - Installing phpdocumentor/reflection-docblock (4.3.0): Loading from cache - Installing phpunit/php-token-stream (3.0.1): Downloading (100%) - Installing sebastian/version (2.0.1): Loading from cache - Installing sebastian/resource-operations (2.0.1): Downloading (connecting...Downloading (100%) - Installing sebastian/recursion-context (3.0.0): Loading from cache - Installing sebastian/object-reflector (1.1.1): Loading from cache - Installing sebastian/object-enumerator (3.0.3): Loading from cache - Installing sebastian/global-state (2.0.0): Loading from cache - Installing sebastian/exporter (3.1.0): Loading from cache - Installing sebastian/environment (4.0.1): Downloading (100%) - Installing sebastian/diff (3.0.1): Loading from cache - Installing sebastian/comparator (3.0.2): Loading from cache - Installing phpunit/php-timer (2.0.0): Loading from cache - Installing phpunit/php-text-template (1.2.1): Loading from cache - Installing phpunit/php-file-iterator (2.0.2): Loading from cache - Installing theseer/tokenizer (1.1.0): Loading from cache - Installing sebastian/code-unit-reverse-lookup (1.0.1): Loading from cache - Installing phpunit/php-code-coverage (6.1.4): Downloading (100%) - Installing doctrine/instantiator (1.1.0): Loading from cache - Installing phpspec/prophecy (1.8.0): Loading from cache - Installing phar-io/version (2.0.1): Loading from cache - Installing phar-io/manifest (1.0.3): Loading from cache - Installing myclabs/deep-copy (1.8.1): Loading from cache - Installing phpunit/phpunit (7.5.2): Downloading (100%) symfony/routing suggests installing doctrine/annotations (For using the annotation loader) symfony/routing suggests installing symfony/config (For using the all-in-one router or any loader) symfony/routing suggests installing symfony/dependency-injection (For loading routes from a service) symfony/routing suggests installing symfony/expression-language (For using expression matching) symfony/routing suggests installing symfony/yaml (For using the YAML loader) symfony/contracts suggests installing psr/cache (When using the Cache contracts) symfony/contracts suggests installing symfony/cache-contracts-implementation () symfony/contracts suggests installing symfony/service-contracts-implementation () symfony/event-dispatcher suggests installing symfony/dependency-injection () symfony/http-kernel suggests installing symfony/browser-kit () symfony/http-kernel suggests installing symfony/config () symfony/http-kernel suggests installing symfony/dependency-injection () symfony/console suggests installing symfony/lock () swiftmailer/swiftmailer suggests installing true/punycode (Needed to support internationalized email addresses, if ext-intl is not installed) paragonie/random_compat suggests installing ext-libsodium (Provides a modern crypto API that can be used to generate random bytes.) ramsey/uuid suggests installing ext-libsodium (Provides the PECL libsodium extension for use with the SodiumRandomGenerator) ramsey/uuid suggests installing ext-uuid (Provides the PECL UUID extension for use with the PeclUuidTimeGenerator and PeclUuidRandomGenerator) ramsey/uuid suggests installing ircmaxell/random-lib (Provides RandomLib for use with the RandomLibAdapter) ramsey/uuid suggests installing moontoast/math (Provides support for converting UUID to 128-bit integer (in string form).) ramsey/uuid suggests installing ramsey/uuid-console (A console application for generating UUIDs with ramsey/uuid) ramsey/uuid suggests installing ramsey/uuid-doctrine (Allows the use of Ramsey\Uuid\Uuid as Doctrine field type.) symfony/translation suggests installing symfony/config () symfony/translation suggests installing symfony/yaml () nesbot/carbon suggests installing friendsofphp/php-cs-fixer (Needed for the `composer phpcs` command. Allow to automatically fix code style.) nesbot/carbon suggests installing phpstan/phpstan (Needed for the `composer phpstan` command. Allow to detect potential errors.) monolog/monolog suggests installing aws/aws-sdk-php (Allow sending log messages to AWS services like DynamoDB) monolog/monolog suggests installing doctrine/couchdb (Allow sending log messages to a CouchDB server) monolog/monolog suggests installing ext-amqp (Allow sending log messages to an AMQP server (1.0+ required)) monolog/monolog suggests installing ext-mongo (Allow sending log messages to a MongoDB server) monolog/monolog suggests installing graylog2/gelf-php (Allow sending log messages to a GrayLog2 server) monolog/monolog suggests installing mongodb/mongodb (Allow sending log messages to a MongoDB server via PHP Driver) monolog/monolog suggests installing php-amqplib/php-amqplib (Allow sending log messages to an AMQP server using php-amqplib) monolog/monolog suggests installing php-console/php-console (Allow sending log messages to Google Chrome) monolog/monolog suggests installing rollbar/rollbar (Allow sending log messages to Rollbar) monolog/monolog suggests installing ruflin/elastica (Allow sending log messages to an Elastic Search server) monolog/monolog suggests installing sentry/sentry (Allow sending log messages to a Sentry server) league/flysystem suggests installing league/flysystem-aws-s3-v2 (Allows you to use S3 storage with AWS SDK v2) league/flysystem suggests installing league/flysystem-aws-s3-v3 (Allows you to use S3 storage with AWS SDK v3) league/flysystem suggests installing league/flysystem-azure (Allows you to use Windows Azure Blob storage) league/flysystem suggests installing league/flysystem-cached-adapter (Flysystem adapter decorator for metadata caching) league/flysystem suggests installing league/flysystem-eventable-filesystem (Allows you to use EventableFilesystem) league/flysystem suggests installing league/flysystem-rackspace (Allows you to use Rackspace Cloud Files) league/flysystem suggests installing league/flysystem-sftp (Allows you to use SFTP server storage via phpseclib) league/flysystem suggests installing league/flysystem-webdav (Allows you to use WebDAV storage) league/flysystem suggests installing league/flysystem-ziparchive (Allows you to use ZipArchive adapter) league/flysystem suggests installing spatie/flysystem-dropbox (Allows you to use Dropbox storage) league/flysystem suggests installing srmklive/flysystem-dropbox-v2 (Allows you to use Dropbox storage for PHP 5 applications) lcobucci/jwt suggests installing mdanter/ecc (Required to use Elliptic Curves based algorithms.) laravel/framework suggests installing aws/aws-sdk-php (Required to use the SQS queue driver and SES mail driver (^3.0).) laravel/framework suggests installing doctrine/dbal (Required to rename columns and drop SQLite columns (^2.6).) laravel/framework suggests installing league/flysystem-aws-s3-v3 (Required to use the Flysystem S3 driver (^1.0).) laravel/framework suggests installing league/flysystem-cached-adapter (Required to use the Flysystem cache (^1.0).) laravel/framework suggests installing league/flysystem-rackspace (Required to use the Flysystem Rackspace driver (^1.0).) laravel/framework suggests installing league/flysystem-sftp (Required to use the Flysystem SFTP driver (^1.0).) laravel/framework suggests installing moontoast/math (Required to use ordered UUIDs (^1.1).) laravel/framework suggests installing pda/pheanstalk (Required to use the beanstalk queue driver (^3.0).) laravel/framework suggests installing predis/predis (Required to use the redis cache and queue drivers (^1.0).) laravel/framework suggests installing pusher/pusher-php-server (Required to use the Pusher broadcast driver (^3.0).) laravel/framework suggests installing symfony/dom-crawler (Required to use most of the crawler integration testing tools (^4.1).) laravel/framework suggests installing symfony/psr-http-message-bridge (Required to psr7 bridging features (^1.0).) psy/psysh suggests installing ext-pdo-sqlite (The doc command requires SQLite to work.) psy/psysh suggests installing hoa/console (A pure PHP readline implementation. You'll want this if your PHP install doesn't already support readline or libedit.) filp/whoops suggests installing whoops/soap (Formats errors as SOAP responses) sebastian/global-state suggests installing ext-uopz (*) phpunit/phpunit suggests installing ext-soap (*) phpunit/phpunit suggests installing phpunit/php-invoker (^2.0) Generating optimized autoload files > @php -r "file_exists('.env') || copy('.env.example', '.env');" > @php artisan key:generate --ansi Application key set successfully. > Illuminate\Foundation\ComposerScripts::postAutoloadDump > @php artisan package:discover --ansi Discovered Package: beyondcode/laravel-dump-server Discovered Package: fideloper/proxy Discovered Package: laravel/nexmo-notification-channel Discovered Package: laravel/slack-notification-channel Discovered Package: laravel/tinker Discovered Package: nesbot/carbon Discovered Package: nunomaduro/collision Package manifest generated successfully. Application ready! Build something amazing. |
Go to the project directory and check the laravel version:
|
|
teddy@teddy:~/Documents/works/laravel$ cd laravel-cms/ teddy@teddy:~/Documents/works/laravel/laravel-cms$ php artisan help -V Laravel Framework 5.7.21 |
Run it for the first time:
|
|
teddy@teddy:~/Documents/works/laravel/laravel-cms$ php artisan serve |
Open it on your browser : http://localhost:8000/
Set up the database (MySql)
Create a new database ‘laravel_cms’
then modify .env file to match your mysql server setting and database
SETTING UP USER ROLES
Like most content management systems, we are going to have a user role system so that our blog can have multiple types of users; the admin and regular user. The admin should be able to create a post and perform other CRUD operations on a post. The regular user, on the other hand, should be able to view and comment on a post.
For us to implement this functionality, we need to implement user authentication and add a simple role authorization system.
SETTING UP USER AUTHENTICATION
Laravel provides user authentication out of the box, which is great, and we can key into the feature by running a single command:
|
|
teddy@teddy:~/Documents/works/laravel/laravel-cms$ php artisan make:auth Authentication scaffolding generated successfully. |
The above will create all that’s necessary for authentication in our application so we do not need to do anything extra.
SETTING UP ROLE AUTHORIZATION
We need a model for the user roles so let’s create one and an associated migration file:
|
|
teddy@teddy:~/Documents/works/laravel/laravel-cms$ php artisan make:model Role -m Model created successfully. Created Migration: 2019_01_21_031440_create_roles_table |
In the database/migrations folder, find the newly created migration file and update the CreateRolesTable class (2019_01_21_031440_create_roles_table.php) with this snippet:
Update with adding two new fields: $table->string(‘name’) and
$table->string(‘description’):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33
|
//laravel-cms/database/migrations/2019_01_21_031440_create_roles_table.php use Illuminate\Support\Facades\Schema; use Illuminate\Database\Schema\Blueprint; use Illuminate\Database\Migrations\Migration; class CreateRolesTable extends Migration { /** * Run the migrations. * * @return void */ public function up() { Schema::create('roles', function (Blueprint $table) { $table->increments('id'); $table->string('name'); $table->string('description'); $table->timestamps(); }); } /** * Reverse the migrations. * * @return void */ public function down() { Schema::dropIfExists('roles'); } } |
We intend to create a many-to-many relationship between the User and Role models so let’s add a relationship method on both models.
Open the User model and add the following method:
|
|
//laravel/laravel-cms/app/User.php public function roles(){ return $this->belongsToMany(Role::class); } |
Open the Role model and include the following method:
|
|
//laravel-cms/app/Role.php public function users(){ return $this->belongsToMany(User::class); } |
We are also going to need a pivot table to associate each user with a matching role so let’s create a new migration file for the role_user table:
|
|
teddy@teddy:~/Documents/works/laravel/laravel-cms$ php artisan make:migration create_role_user_table Created Migration: 2019_01_21_032646_create_role_user_table |
In the database/migrations folder, find the newly created migration file and update the CreateRoleUserTable class with this snippet:
|
|
//laravel-cms/database/migrations/2019_01_21_032646_create_role_user_table.php public function up() { Schema::create('role_user', function (Blueprint $table) { $table->increments('id'); $table->integer('role_id')->unsigned(); $table->integer('user_id')->unsigned(); //$table->timestamps(); }); } |
Next, let’s create seeders that will populate the users and roles tables with some data. In your terminal, run the following command to create the database seeders:
|
|
teddy@teddy:~/Documents/works/laravel/laravel-cms$ php artisan make:seeder RoleTableSeeder Seeder created successfully. teddy@teddy:~/Documents/works/laravel/laravel-cms$ php artisan make:seeder UserTableSeeder Seeder created successfully. |
In the database/seeds folder, open the RoleTableSeeder.php file and replace the contents with the following code:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24
|
//laravel-cms/database/seeds/RoleTableSeeder.php use Illuminate\Database\Seeder; use App\Role; class RoleTableSeeder extends Seeder { /** * Run the database seeds. * * @return void */ public function run() { $role_regular_user = new Role; $role_regular_user->name = 'user'; $role_regular_user->description = 'A regular user'; $role_regular_user->save(); $role_admin_user = new Role; $role_admin_user->name = 'admin'; $role_admin_user->description = 'An admin user'; $role_admin_user->save(); } } |
Open the UserTableSeeder.php file and replace the contents with the following code:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30
|
//laravel-cms/database/seeds/UserTableSeeder.php use Illuminate\Database\Seeder; use Illuminate\Support\Facades\Hash; use App\User; use App\Role; class UserTableSeeder extends Seeder { /** * Run the database seeds. * * @return void */ public function run() { $user = new User; $user->name = 'Satria Faestha'; $user->email = 'satriaf@yahoo.com'; $user->password = bcrypt('teddy'); $user->save(); $user->roles()->attach(Role::where('name', 'user')->first()); $admin = new User; $admin->name = 'Admin'; $admin->email = 'advcha@yahoo.com'; $admin->password = bcrypt('teddy'); $admin->save(); $admin->roles()->attach(Role::where('name', 'admin')->first()); } } |
We also need to update the DatabaseSeeder class. Open the file and update the run method as seen below:
|
|
//laravel-cms/database/seeds/DatabaseSeeder.php public function run() { $this->call([ RoleTableSeeder::class, UserTableSeeder::class, ]); } |
Next, let’s update the User model. We will be adding a checkRoles method that checks what role a user has. We will return a 404 page where a user doesn’t have the expected role for a page. Open the User model and add these methods:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
|
//laravel-cms/app/User.php public function checkRoles($roles){ if(!is_array($roles)){ $roles = [$roles]; } if(!$this->hasAnyRole($roles)){ auth()->logout(); abort(404); } } public function hasAnyRole($roles):bool { return (bool) $this->roles()->whereIn('name', $roles)->first(); } public function hasRole($role):bool { return (bool)$this->roles()->where('name', $role)->first(); } |
Let’s modify the RegisterController.php file in the Controllers/Auth folder so that a default role, the user role, is always attached to a new user at registration.
Open the RegisterController and update the create action with the following code:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
|
//laravel-cms/app/Http/Controllers/Auth/RegisterController.php protected function create(array $data) { /*return User::create([ 'name' => $data['name'], 'email' => $data['email'], 'password' => Hash::make($data['password']), ]);*/ $user = User::create([ 'name' => $data['name'], 'email' => $data['email'], 'password' => Hash::make($data['password']), ]); $user->roles()->attach(\App\Role::where('name', 'user')->first()); return $user; } |
Now let’s migrate and seed the database so that we can log in with the sample accounts. To do this, run the following command in your terminal:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31
|
teddy@teddy:~/Documents/works/laravel/laravel-cms$ php artisan migrate:fresh --seed Dropped all tables successfully. Migration table created successfully. Migrating: 2014_10_12_000000_create_users_table Migrated: 2014_10_12_000000_create_users_table Migrating: 2014_10_12_100000_create_password_resets_table Migrated: 2014_10_12_100000_create_password_resets_table Migrating: 2019_01_21_031440_create_roles_table Illuminate\Database\QueryException : SQLSTATE[42S21]: Column already exists: 1060 Duplicate column name 'created_at' (SQL: create table `roles` (`created_at` timestamp null, `updated_at` timestamp null, `name` varchar(255) not null, `description` varchar(255) not null, `created_at` timestamp null, `updated_at` timestamp null) default character set utf8mb4 collate 'utf8mb4_unicode_ci') at /home/teddy/Documents/works/laravel/laravel-cms/vendor/laravel/framework/src/Illuminate/Database/Connection.php:664 660| // If an exception occurs when attempting to run a query, we'll format the error 661| // message to include the bindings with SQL, which will make this exception a 662| // lot more helpful to the developer instead of just the database's errors. 663| catch (Exception $e) { > 664| throw new QueryException( 665| $query, $this->prepareBindings($bindings), $e 666| ); 667| } 668| Exception trace: 1 PDOException::("SQLSTATE[42S21]: Column already exists: 1060 Duplicate column name 'created_at'") /home/teddy/Documents/works/laravel/laravel-cms/vendor/laravel/framework/src/Illuminate/Database/Connection.php:458 2 PDOStatement::execute() /home/teddy/Documents/works/laravel/laravel-cms/vendor/laravel/framework/src/Illuminate/Database/Connection.php:458 Please use the argument -v to see more details. |
SOLUTION: check again ‘CreateRolesTable’ Class in /home/teddy/Documents/works/laravel/laravel-cms/database/migrations/2019_01_21_031440_create_roles_table.php. Somehow the ‘up’ function is changed!!!
|
|
Illuminate\Database\QueryException : SQLSTATE[23000]: Integrity constraint violation: 1048 Column 'role_id' cannot be null (SQL: insert into `role_user` (`role_id`, `user_id`) values (, 1)) |
SOLUTION: It’s related with the above problem!
This is working:
|
|
teddy@teddy:~/Documents/works/laravel/laravel-cms$ php artisan migrate:fresh --seed Dropped all tables successfully. Migration table created successfully. Migrating: 2014_10_12_000000_create_users_table Migrated: 2014_10_12_000000_create_users_table Migrating: 2014_10_12_100000_create_password_resets_table Migrated: 2014_10_12_100000_create_password_resets_table Migrating: 2019_01_21_031440_create_roles_table Migrated: 2019_01_21_031440_create_roles_table Migrating: 2019_01_21_032646_create_role_user_table Migrated: 2019_01_21_032646_create_role_user_table Seeding: RoleTableSeeder Seeding: UserTableSeeder Database seeding completed successfully. |
In order to test that our roles work as they should, we will make an update to the HomeController.php file. Open the HomeController and update the index method as seen below:
|
|
//laravel-cms/app/Http/Controllers/HomeController.php public function index(Request $request) { $request->user()->checkRoles('admin'); return view('home'); } |
Now, only administrators should be able to see the dashboard. In a more complex application, we would use a middleware to do this instead.
We can test that this works by serving the application and logging (http://localhost:8000/login) in both user accounts: satriaf@yahoo.com (user) and advcha@yahoo.com (admin).
Remember that in our UserTableSeeder.php file, we defined satriaf@yahoo.com as a regular user and advcha@yahoo.com as an admin, so satriaf@yahoo.com should see a 404 error after logging in and advcha@yahoo.com should be able to see the homepage. Also http://localhost:8000/home would be redirected to http://localhost:8000/login if none logged in!
We will also confirm that whenever a new user registers, he is assigned a role and it is the role of a regular user. We will create a new user and call him user1@yahoo.com (password: user1_123) , he should see a 404 error right after:
It works just as we wanted it to, however, it doesn’t really make any sense for us to redirect a regular user to a 404 page. Instead, we will edit the HomeController so that it redirects users based on their roles, that is, it redirects a regular user to a regular homepage and an admin to an admin dashboard.
Open the HomeController.php file and update the index method as seen below:
|
|
//laravel-cms/app/Http/Controllers/HomeController.php public function index(Request $request) { //$request->user()->checkRoles('admin'); if($request->user()->hasRole('user')){ return redirect('/'); } if($request->user()->hasRole('admin')){ return redirect('/admin/dashboard'); } //return view('home'); } |
After we login as an user, we missed the logout link here. So modify the main template welcome.blade.php to add the logout link:
|
|
//laravel-cms/resources/views/welcome.blade.php @if (Route::has('login')) <div class="top-right links"> @auth <a href="{{ url('/home') }}">Home</a> <a href="{{ url('/logout') }}">Logout</a> @else <a href="{{ route('login') }}">Login</a> @if (Route::has('register')) <a href="{{ route('register') }}">Register</a> @endif @endauth </div> @endif |
But if we click the logout link, it’d show an error:
|
|
Symfony \ Component \ HttpKernel \ Exception \ MethodNotAllowedHttpException No message |
It means we need to add a route for ‘logout’. Add the route in web.php:
|
|
//laravel/laravel-cms/routes/web.php Route::get('/logout', 'Auth\LoginController@logout')->name('logout' ); |
Ref: https://stackoverflow.com/questions/44716379/laravel-5-4-24-throws-methodnotallowedhttpexception-during-logout-of-users
If we serve our application and try to log in using the admin account, we will hit a 404 error because we do not have a controller or a view for the admin/dashboard route. Let’s create the AdminController with this command:
|
|
teddy@teddy:~/Documents/works/laravel/laravel-cms$ php artisan make:controller AdminController Controller created successfully. |
We will add the /admin/ route to our routes/web.php file:
|
|
//laravel-cms/routes/web.php Route::get('/admin/{any}', 'AdminController@index')->where('any', '.*'); |
Note that we wrote /admin/{any} here because we intend to serve every page of the admin dashboard using the Vue router. When we start building the admin dashboard in the next article, we will let Vue handle all the routes of the /admin pages.
Let’s update the AdminController.php file to use the auth middleware and include an index() action method:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
|
//laravel-cms/app/Http/Controllers/AdminController.php class AdminController extends Controller { public function __construct(){ $this->middleware('auth'); } public function index(){ if(request()->user()->hasRole('admin')){ return view('admin.dashboard'); } if(request()->user()->hasRole('user')){ return redirect('/'); } } } |
In the index() action method, we included a snippet that will ensure that only admin users can visit the admin dashboard and perform CRUD operations on posts. If we try to login as an admin, it’d show this error:
|
|
InvalidArgumentException View [admin.dashboard] not found. |
Because we haven’t create any view for the admin route. We’ll create it with vue router later.
Here, we will create the Post model and start building the frontend for the application.
Our application allows different levels of accessibility for two kinds of users; the regular user and admin. Now, we will focus on building the view that the regular users are permitted to see.
Before we build any views, let’s create the Post model as it is imperative to rendering the view. We will create the Post model with an associated resource controller and a migration file using this command:
|
|
teddy@teddy:~/Documents/works/laravel/laravel-cms$ php artisan make:model Post -mr Model created successfully. Created Migration: 2019_01_21_083457_create_posts_table Controller created successfully. |
We added the r flag because we want the controller to be a resource controller. The m flag will generate a migration for the model.
Let’s navigate into the database/migrations folder and update the CreatePostsTable class that was generated for us:
|
|
//laravel-cms/database/migrations/2019_01_21_083457_create_posts_table.php public function up() { Schema::create('posts', function (Blueprint $table) { $table->increments('id'); $table->integer('user_id')->unsigned(); $table->string('title'); $table->text('body'); $table->binary('image')->nullable(); $table->timestamps(); }); } |
We included a user_id property because we want to create a relationship between the User and Post models. A Post also has an image field, which is where its associated image’s address will be stored.
CREATING A DATABASE SEEDER FOR THE POST TABLE
We will create a new seeder file for the posts table using this command:
|
|
teddy@teddy:~/Documents/works/laravel/laravel-cms$ php artisan make:seeder PostTableSeeder Seeder created successfully. |
Let’s navigate into the database/seeds folder and update the PostTableSeeder.php file:
|
|
//laravel-cms/database/seeds/PostTableSeeder.php public function run() { $post = new Post; $post->user_id = 2; $post->title = "Using Laravel Seeders"; $post->body = "Laravel includes a simple method of seeding your database with test data using seed classes. All seed classes are stored in the database/seeds directory. Seed classes may have any name you wish, but probably should follow some sensible convention, such as UsersTableSeeder, etc. By default, a DatabaseSeeder class is defined for you. From this class, you may use the call method to run other seed classes, allowing you to control the seeding order."; $post->save(); $post = new Post; $post->user_id = 2; $post->title = "Database: Migrations"; $post->body = "Migrations are like version control for your database, allowing your team to easily modify and share the application's database schema. Migrations are typically paired with Laravel's schema builder to easily build your application's database schema. If you have ever had to tell a teammate to manually add a column to their local database schema, you've faced the problem that database migrations solve."; $post->save(); } |
Let’s open the DatabaseSeeder and update it with the following code:
|
|
//laravel-cms/database/seeds/DatabaseSeeder.php public function run() { $this->call([ RoleTableSeeder::class, UserTableSeeder::class, PostTableSeeder::class, ]); } |
When we run this seeder, it will create two new posts and assign both of them to the admin user whose ID is 2. We are attaching both posts to the admin user because the regular users are only allowed to view posts and make comments; they can’t create a post. We will use this command to migrate our tables and seed the database:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
|
teddy@teddy:~/Documents/works/laravel/laravel-cms$ php artisan migrate:fresh --seed Dropped all tables successfully. Migration table created successfully. Migrating: 2014_10_12_000000_create_users_table Migrated: 2014_10_12_000000_create_users_table Migrating: 2014_10_12_100000_create_password_resets_table Migrated: 2014_10_12_100000_create_password_resets_table Migrating: 2019_01_21_031440_create_roles_table Migrated: 2019_01_21_031440_create_roles_table Migrating: 2019_01_21_032646_create_role_user_table Migrated: 2019_01_21_032646_create_role_user_table Migrating: 2019_01_21_083457_create_posts_table Migrated: 2019_01_21_083457_create_posts_table Seeding: RoleTableSeeder Seeding: UserTableSeeder Seeding: PostTableSeeder Database seeding completed successfully. |
DEFINING THE RELATIONSHIPS
Just as we previously created a many-to-many relationship between the User and Role models, we need to create a different kind of relationship between the Post and User models.
We will define the relationship as a one-to-many relationship because a user will have many posts but a post will only ever belong to one user.
Open the User model and include the method below:
|
|
// File: ./app/User.php public function posts() { return $this->hasMany(Post::class); } |
Open the Post model and include the method below:
|
|
// File: ./app/Post.php public function user() { return $this->belongsTo(User::class); } |
SETTING UP THE ROUTES
At this point in our application, we do not have a front page with all the posts listed. Let’s create so anyone can see all of the created posts. Asides from the front page, we also need a single post page in case a user needs to read a specific post.
Let’s include two new routes to our routes/web.php file:
The first route will match requests to the root of our application and will be handled by the PostController@all action:
|
|
Route::get('/', 'PostController@all'); |
In the routes/web.php file, there will already be a route definition for the / address, you will have to replace it with the new route definition above.
The second route will handle requests for specific Post items and will be handled by the PostController@single action:
|
|
Route::get('/posts/{post}', 'PostController@single'); |
With these two new routes added, here’s what the routes/web.php file should look like this:
|
|
//laravel-cms/routes/web.php /*Route::get('/', function () { return view('welcome'); });*/ Auth::routes(); Route::get('/home', 'HomeController@index')->name('home'); Route::get('/logout', 'Auth\LoginController@logout')->name('logout' ); Route::get('/', PostController@all); Route::get('/posts/{post}', PostController@single); |
SETTING UP THE POST CONTROLLER
In this section, we want to define the handler action methods that we registered in the routes/web.php file so that our application know how to render the matching views.
First, let’s add the all() method:
|
|
// File: ./app/Http/Controllers/PostController.php public function all() { return view('landing', [ 'posts' => Post::latest()->paginate(5) ]); } |
Here, we want to retrieve five created posts per page and send to the landing view. We will create this view shortly.
Next, let’s add the single() method to the controller:
|
|
// File: ./app/Http/Controllers/PostController.php public function single(Post $post) { return view('single', compact('post')); } |
In the method above, we used a feature of Laravel named route model binding to map the URL parameter to a Post instance with the same ID. We are returning a single view, which we will create shortly. This will be the view for the single post page.
BUILDING OUR VIEWS
Laravel uses a templating engine called Blade for its frontend. We will use Blade to build these parts of the frontend before switching to Vue in the next chapter.
Navigate to the resources/views folder and create two new Blade files. They are landing.blade.php and single.blade.php
These are the files that will load the views for the landing page and single post page. Before we start writing any code in these files, we want to create a simple layout template that our page views can use as a base.
In the resources/views/layouts folder, create a Blade template file and call it master.blade.php. This is where we will define the inheritable template for our single and landing pages.
Open the master.blade.php file and update it with this code:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66
|
<!-- File: ./resources/views/layouts/master.blade.php --> <!DOCTYPE html> <html lang="en"> <head> <meta charset="utf-8"> <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no"> <meta name="description" content=""> <meta name="author" content="Satria Faestha"> <title>LaravelCMS</title> <link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.1.3/css/bootstrap.min.css"> <style> body { padding-top: 54px; } @media (min-width: 992px) { body { padding-top: 56px; } } </style> </head> <body> <nav class="navbar navbar-expand-lg navbar-dark bg-dark fixed-top"> <div class="container"> <a class="navbar-brand" href="/">LaravelCMS</a> <div class="collapse navbar-collapse" id="navbarResponsive"> <ul class="navbar-nav ml-auto"> @if (Route::has('login')) @auth <li class="nav-item"> <a class="nav-link" href="{{ url('/home') }}">Home</a> </li> <li class="nav-item"> <a class="nav-link" href="{{ route('logout') }}" onclick="event.preventDefault();document.getElementById('logout-form').submit();"> Log out ({{ Auth::user()->name }}) </a> <form id="logout-form" action="{{ route('logout') }}" method="POST" style="display: none;"> @csrf </form> </li> @else <li class="nav-item"> <a class="nav-link" href="{{ route('login') }}">Login</a> </li> <li class="nav-item"> <a class="nav-link" href="{{ route('register') }}">Register</a> </li> @endauth @endif </ul> </div> </div> </nav> <div id="app"> @yield('content') </div> <footer class="py-5 bg-dark"> <div class="container"> <p class="m-0 text-center text-white">Copyright © LaravelCMS 2018</p> </div> </footer> </body> </html> |
Now we can inherit this template in the landing.blade.php file, open it and update it with this code:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28
|
{{-- File: ./resources/views/landing.blade.php --}} @extends('layouts.master') @section('content') <div class="container"> <div class="row align-items-center"> <div class="col-md-8 mx-auto"> <h1 class="my-4 text-center">Welcome to the Blog </h1> @foreach ($posts as $post) <div class="card mb-4"> <img class="card-img-top" src=" {!! !empty($post->image) ? '/uploads/posts/' . $post->image : 'http://placehold.it/750x300' !!} " alt="Card image cap"> <div class="card-body"> <h2 class="card-title text-center">{{ $post->title }}</h2> <p class="card-text"> {{ str_limit($post->body, $limit = 280, $end = '...') }} </p> <a href="/posts/{{ $post->id }}" class="btn btn-primary">Read More →</a> </div> <div class="card-footer text-muted"> Posted {{ $post->created_at->diffForHumans() }} by <a href="#">{{ $post->user->name }} </a> </div> </div> @endforeach </div> </div> </div> @endsection |
Let’s do the same with the single.blade.php file, open it and update it with this code:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30
|
{{-- File: ./resources/views/single.blade.php --}} @extends('layouts.master') @section('content') <div class="container"> <div class="row"> <div class="col-lg-10 mx-auto"> <h3 class="mt-4">{{ $post->title }} <span class="lead"> by <a href="#"> {{ $post->user->name }} </a></span> </h3> <hr> <p>Posted {{ $post->created_at->diffForHumans() }} </p> <hr> <img class="img-fluid rounded" src=" {!! !empty($post->image) ? '/uploads/posts/' . $post->image : 'http://placehold.it/750x300' !!} " alt=""> <hr> <p class="lead">{{ $post->body }}</p> <hr> <div class="card my-4"> <h5 class="card-header">Leave a Comment:</h5> <div class="card-body"> <form> <div class="form-group"> <textarea class="form-control" rows="3"></textarea> </div> <button type="submit" class="btn btn-primary">Submit</button> </form> </div> </div> </div> </div> </div> @endsection |
TESTING THE APPLICATION
We can test the application to see that things work as we expect. When we serve the application, we expect to see a landing page and a single post page. We also expect to see two posts because that’s the number of posts we seeded into the database.
We will serve the application using this command:
We have used simple placeholder images here because we haven’t built the admin dashboard that allows CRUD operations to be performed on posts.
In the coming chapters, we will add the ability for an admin to include a custom image when creating a new post.
CONCLUSION
In this chapter, we created the Post model and defined a relationship on it to the User model. We also built the landing page and single page.
We will start building the admin frontend of the CMS. This is the first part of the series where we will integrate Vue and explore Vue’s magical abilities.
Laravel ships with Vue out of the box so we do not need to use the Vue-CLI or reference Vue from a CDN. Please see package.json file on the project root.
|
|
//laravel-cms/package.json "devDependencies": { "axios": "^0.18", "bootstrap": "^4.0.0", "cross-env": "^5.1", "jquery": "^3.2", "laravel-mix": "^4.0.7", "lodash": "^4.17.5", "popper.js": "^1.12", "resolve-url-loader": "^2.3.1", "sass": "^1.15.2", "sass-loader": "^7.1.0", "vue": "^2.5.17" }, |
This makes it possible for us to have all of our application, the frontend, and backend, in a single codebase.
Every newly created instance of a Laravel installation has some Vue files included by default, we can see these files when we navigate into the resources/js/components folder.
SETTING UP VUE AND VUEROUTER
Before we can start using Vue in our application, we need to first install some dependencies using NPM. To install the dependencies that come by default with Laravel, run the command below:
We will be managing all of the routes for the admin dashboard using vue-router so let’s pull it in:
|
|
npm install --save vue-router |
When the installation is complete, the next thing we want to do is open the resources/js/app.js file and replace its contents with the code below:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27
|
// File: ./resources/js/app.js require('./bootstrap'); import Vue from 'vue' import VueRouter from 'vue-router' import Homepage from './components/Homepage' import Read from './components/Read' Vue.use(VueRouter) const router = new VueRouter({ mode: 'history', routes: [ { path: '/admin/dashboard', name: 'read', component: Read, props: true }, ], }); const app = new Vue({ el: '#app', router, components: { Homepage }, }); |
In the snippet above, we imported the VueRouter and added it to the Vue application. We also imported a Homepage and a Read component. These are the components where we will write our markup so let’s create both files.
NOTE: IF YOU EDIT THE JS/VUE TEMPLATE, DON’T FORGET TO STOP ‘php artisan serve’ THEN RUN ‘npm run dev’ THEN START AGAIN ‘php artisan serve’. IF NOT, YOU’D NOT SEE THE CHANGES YOU MADE!!!
Open the resources/js/components folder and create four files:
Homepage.vue – this will be the parent component for the admin dashboard frontend.
Read.vue – this will be component that displays all the available posts on the admin dashboard.
Create.vue – this will be the component where an admin user can create a new post.
Update.vue – this will be the component that displays the view where an admin user can update an existing post.
Note that we didn’t create a component file for the delete operation, this is because it is going to be possible to delete a post from the Read component. There is no need for a view.
In the resources/js/app.js file, we defined a routes array and in it, we registered a read route. During render time, this route’s path will be mapped to the Read component.
In the previous article, we specified that admin users should be shown an admin.dashboard view in the index method, however, we didn’t create this view. Let’s create the view. Open the resources/views folder and create a new folder called admin. Within the new resources/views/admin folder, create a new file and called dashboard.blade.php. This is going to be the entry point to the admin dashboard, further from this route, we will let the Vue Router handle everything else.
Open the resources/views/admin/dashboard.blade.php file and paste in the following code:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25
|
<!-- File: ./resources/views/admin/dashboard.blade.php --> <!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta http-equiv="X-UA-Compatible" content="ie=edge"> <title> Welcome to the Admin dashboard </title> <link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.1.3/css/bootstrap.min.css"> <style> html, body { background-color: #202B33; color: #738491; font-family: "Open Sans"; font-size: 16px; font-smoothing: antialiased; overflow: hidden; } </style> </head> <body> <script src="{{ asset('js/app.js') }}"></script> </body> </html> |
Our goal here is to integrate Vue into the application, so we included the resources/js/app.js file with this line of code:
|
|
<script src="{{ asset('js/app.js') }}"></script> |
For our app to work, we need a root element to bind our Vue instance unto. Before the <script> tag, add this snippet of code:
|
|
... <body> <div id="app"> <Homepage :user-name='@json(auth()->user()->name)' :user-id='@json(auth()->user()->id)'> </Homepage> </div> <script src="{{ asset('js/app.js') }}"></script> </body> ... |
We earlier defined the Homepage component as the wrapping component, that’s why we pulled it in here as the root component. For some of the frontend components to work correctly, we require some details of the logged in admin user to perform CRUD operations. This is why we passed down the userName (:user-name) and userId (:user-id) props to the Homepage component.
We need to prevent the CSRF error from occurring in our Vue frontend, so include this snippet of code just before the <title> tag:
|
|
<meta name="csrf-token" content="{{ csrf_token() }}"> <script> window.Laravel = { csrfToken: 'csrf_token() ' } </script> |
This snippet will ensure that the correct token is always included in our frontend, Laravel provides the CSRFprotection for us out of the box.
SETTING UP THE HOMEPAGE VIEW
Open the Homepage.vue file that we created some time ago and include this markup template:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28
|
<!-- File: ./resources/js/components/Homepage.vue --> <template> <div> <nav> <section> <a style="color: white" href="/admin/dashboard">Laravel-CMS</a> || <a style="color: white" href="/">HOME</a> <hr> <ul> <li> <router-link :to="{ name: 'create', params: { userId } }"> NEW POST </router-link> </li> </ul> </section> </nav> <article> <header> <header class="d-inline">Welcome, {{ userName }}</header> <p @click="logout" class="float-right mr-3" style="cursor: pointer">Logout</p> </header> <div> <router-view></router-view> </div> </article> </div> </template> |
We added a router-link in this template, which routes to the Create component.
We are passing the userId data to the create component because a userId is required during Post creation.
UPDATED: I want to pass more variables (like user-email and user-admin. DON’T USE SINGLE WORD VARIABLE LIKE title. USE DASH SEPARATED VARIABLE) to vue template. So modify resources/views/admin/dashboard.blade.php:
|
|
<body> <div id="app"> <Homepage :user-name='@json(auth()->user()->name)' :user-id='@json(auth()->user()->id)' :user-email='@json(auth()->user()->email)' :user-admin='@json(auth()->user()->hasRole("admin"))'> </Homepage> </div> <script src="{{ asset('js/app.js') }}"></script> </body> |
Then catch the variables in resources/js/components/Homepage.vue:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22
|
<script> export default { name: "Homepage", props: { userId: { type: Number, required: true }, userName: { type: String, required: true }, userEmail: { type: String, required: true }, userAdmin: { type: Boolean, required: true } }, ... |
Here is the passing variables in Vue chrome toolbars
How to get user role name on the vue template???
I need to create a helper function in User model (app/User.php):
|
|
public function getRole(){ return $this->roles()->first()->name; } |
Ref: https://stackoverflow.com/questions/36719279/laravel-5-2-how-to-get-the-role-of-current-user-when-using-zizaco-entrust
Then modify again resources/views/admin/dashboard.blade.php to get the user role with auth()->user()->getRole():
|
|
<Homepage :user-name='@json(auth()->user()->name)' :user-id='@json(auth()->user()->id)' :user-email='@json(auth()->user()->email)' :user-admin='@json(auth()->user()->hasRole("admin"))' :user-role='@json(auth()->user()->getRole())'> </Homepage> |
Then catch the props value in resources/js/components/Homepage.vue:
|
|
props: { ... userRole: { type: String, required: true } }, |
Let’s include some styles so that the page looks good. Below the closing template tag, paste the following code:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55
|
<style scoped> @import url(https://fonts.googleapis.com/css?family=Dosis:300|Lato:300,400,600,700|Roboto+Condensed:300,700|Open+Sans+Condensed:300,600|Open+Sans:400,300,600,700|Maven+Pro:400,700); @import url("https://netdna.bootstrapcdn.com/font-awesome/4.2.0/css/font-awesome.css"); * { -moz-box-sizing: border-box; -webkit-box-sizing: border-box; box-sizing: border-box; } header { color: #d3d3d3; } nav { position: absolute; top: 0; bottom: 0; right: 82%; left: 0; padding: 22px; border-right: 2px solid #161e23; } nav > header { font-weight: 700; font-size: 0.8rem; text-transform: uppercase; } nav section { font-weight: 600; } nav section header { padding-top: 30px; } nav section ul { list-style: none; padding: 0px; } nav section ul a { color: white; text-decoration: none; font-weight: bold; } article { position: absolute; top: 0; bottom: 0; right: 0; left: 18%; overflow: auto; border-left: 2px solid #2a3843; padding: 20px; } article > header { height: 60px; border-bottom: 1px solid #2a3843; } </style> |
We are using the scoped attribute on the <style> tag because we want the CSS to only be applied on the Homepage component.
Next, let’s add the <script> section that will use the props we passed down from the parent component. We will also define the method that controls the log out feature here. Below the closing style tag, paste the following code:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25
|
<script> export default { name: "Homepage", props: { userId: { type: Number, required: true }, userName: { type: String, required: true } }, data() { return {}; }, methods: { logout() { axios.post("/logout").then(() => { window.location = "/"; }); } } }; </script> |
SETTING UP THE READ VIEW
In the resources/js/app.js file, we defined the path of the read component as /admin/dashboard, which is the same address as the Homepage component. This will make sure the Read component always loads by default.
In the Read component, we want to load all of the available posts. We are also going to add Update and Delete options to each post. Clicking on these options will lead to the update and delete views respectively.
Open the Read.vue file and paste the following:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38
|
<!-- File: ./resources/js/components/Read.vue --> <template> <div id="posts"> <p class="border p-3" v-for="post in posts"> {{ post.title }} <router-link :to="{ name: 'update', params: { postId : post.id } }"> <button type="button" class="p-1 mx-3 float-right btn btn-light"> Update </button> </router-link> <button type="button" @click="deletePost(post.id)" class="p-1 mx-3 float-right btn btn-danger" > Delete </button> </p> <div> <button v-if="next" type="button" @click="navigate(next)" class="m-3 btn btn-primary" > Next </button> <button v-if="prev" type="button" @click="navigate(prev)" class="m-3 btn btn-primary" > Previous </button> </div> </div> </template> |
Above, we have the template to handle the posts that are loaded from the API (Will get the data via API later). Next, paste the following below the closing template tag:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29
|
<script> export default { mounted() { this.getPosts(); }, data() { return { posts: {}, next: null, prev: null }; }, methods: { getPosts(address) { axios.get(address ? address : "/api/posts").then(response => { this.posts = response.data.data; this.prev = response.data.links.prev; this.next = response.data.links.next; }); }, deletePost(id) { axios.delete("/api/posts/" + id).then(response => this.getPosts()) }, navigate(address) { this.getPosts(address) } } }; </script> |
In the script above, we defined a getPosts() method that requests a list of posts from the backend server. We also defined a posts object as a data property. This object will be populated whenever posts are received from the backend server.
We defined next and prev data string properties to store pagination links and only display the pagination options where it is available.
Lastly, we defined a deletePost() method that takes the id of a post as a parameter and sends a DELETE request to the API interface using Axios.
TESTING THE APPLICATION
Now that we have completed the first few components, we can serve the application using this command:
We will also build the assets so that our JavaScript is compiled for us. To do this, will run the command below in the root of the project folder:
We can visit the application’s URL http://localhost:8000 and log in as an admin user. It’d show the admin dashboard but no post will show up because we haven’t set up the ‘api’ route yet.
NOTE: If you got an error like
|
|
Unknown custom element: <homepage> - did you register the component correctly? For recursive components, make sure to provide the "name" option. |
It because the app.js file not loaded. Probably you call it wrong. Make sure app.js in this path:
|
|
laravel-cms/resources/js/app.js |
NOT in laravel-cms/resources/assets/js/app.js
We will start building the API for the application. We will create an API for CRUD operations that an admin will perform on posts and we will test the endpoints using Postman.
BUILDING THE API USING LARAVEL’S API RESOURCES
The Laravel framework makes it very easy to build APIs. It has an API resources feature that we can easily adopt in our project. You can think of API resources as a transformation layer between Eloquent models and the JSON responses that will be sent back by our API.
ALLOWING MASS ASSIGNMENT ON SPECIFIED FIELDS
Since we are going to be performing CRUD operations on the posts in the application, we have to explicitly specify that it’s permitted for some fields to be mass-assigned data. For security reasons, Laravel prevents mass assignment of data to model fields by default.
Open the Post.php file and include this line of code:
|
|
// File: ./app/Post.php protected $fillable = ['user_id', 'title', 'body', 'image']; |
DEFINING API ROUTES
We will use the apiResource() method to generate only API routes. Open the routes/api.php file and add the following code:
|
|
// File: ./routes/api.php Route::apiResource('posts', 'PostController'); |
Because we will be handling the API requests on the /posts URL using the PostController, we will have to include some additional action methods in our post controller.
CREATING THE POST RESOURCE
At the beginning of this section, we already talked about what Laravel’s API resources are. Here, we create a resource class for our Post model. This will enable us to retrieve Post data and return formatted JSON format.
To create a resource class for our Post model run the following command in your terminal:
|
|
teddy@teddy:~/Documents/works/laravel/laravel-cms$ php artisan make:resource PostResource Resource created successfully. |
A new PostResource.php file will be available in the app/Http/Resources directory of our application. Open up the PostResource.php file and replace the toArray() method with the following:
|
|
// File: ./app/Http/Resources/PostResource.php public function toArray($request) { //return parent::toArray($request); return [ 'id' => $this->id, 'title' => $this->title, 'body' => $this->body, 'image' => $this->image, 'created_at' => (string) $this->created_at, 'updated_at' => (string) $this->updated_at, ]; } |
The job of this toArray() method is to convert our Post resource into an array. As seen above, we have specified the fields on our Post model, which we want to be returned as JSON when we make a request for posts.
We are also explicitly casting the dates, created_at and update_at, to strings so that they would be returned as date strings. The dates are normally an instance of Carbon.
Now that we have created a resource class for our Post model, we can start building the API’s action methods in our PostController and return instances of the PostResource where we want.
ADDING THE ACTION METHODS TO THE POST CONTROLLER
The usual actions performed on a post include the following:
Create – the process of creating a new post.
Read – the process of reading one or more posts.
Update – the process of updating an already published post.
Delete – the process of deleting a post.
In the last article, we already implemented a kind of ‘Read’ functionality when we defined the all and single methods. These methods allow users to browse through posts on the homepage.
In this section, we will define the methods that will resolve our API requests for creating, reading, updating and deleting posts.
The first thing we want to do is import the PostResource class at the top of the PostController.php file:
|
|
// File: ./app/Http/Controllers/PostController.php use App\Http\Resources\PostResource; |
Because we created the PostController as a resource controller, we already have the resource action methods included for us in the PostController.php file, we will be updating them with fitting snippets of code.
BUILDING THE HANDLER ACTION FOR THE CREATE OPERATION
In the PostController update the store() action method with the code snippet below. It will allow us to validate and create a new post:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28
|
// File: ./app/Http/Controllers/PostController.php public function store(Request $request) { $this->validate($request, [ 'title' => 'required', 'body' => 'required', 'user_id' => 'required', 'image' => 'required|mimes:jpeg,png,jpg,gif,svg', ]); $post = new Post; if ($request->hasFile('image')) { $image = $request->file('image'); $name = str_slug($request->title).'.'.$image->getClientOriginalExtension(); $destinationPath = public_path('/uploads/posts'); $imagePath = $destinationPath . "/" . $name; $image->move($destinationPath, $name); $post->image = $name; } $post->user_id = $request->user_id; $post->title = $request->title; $post->body = $request->body; $post->save(); return new PostResource($post); } |
Here’s a breakdown of what this method does:
Receives a new request.
Validates the request.
Creates a new post.
Returns the post as a PostResource, which in turn returns a JSON formatted response.
BUILDING THE HANDLER ACTION FOR THE READ OPERATIONS
What we want here is to be able to read all the created posts or a single post. This is possible because the apiResource() method defines the API routes using standard REST rules.
This means that a GET request to this address, http://localhost:8000/api/posts, should be resolved by the index() action method. Let’s update the index method with the following code:
|
|
// File: ./app/Http/Controllers/PostController.php public function index() { return PostResource::collection(Post::latest()->paginate(5)); } |
This method will allow us to return a JSON formatted collection of all of the stored posts. We also want to paginate the response as this will allow us to create a better view on the admin dashboard.
Following the RESTful conventions as we discussed above, a GET request to this address, http://localhost:8000/api/posts/id, should be resolved by the show() action method. Let’s update the method with the fitting snippet:
|
|
// File: ./app/Http/Controllers/PostController.php public function show(Post $post) { return new PostResource($post); } |
Awesome, now this method will return a single instance of a post resource upon API query.
BUILDING THE HANDLER ACTION FOR THE UPDATE OPERATION
Next, let’s update the update() method in the PostController class. It will allow us to modify an existing post:
|
|
// File: ./app/Http/Controllers/PostController.php public function update(Request $request, Post $post) { $this->validate($request, [ 'title' => 'required', 'body' => 'required', ]); $post->update($request->only(['title', 'body'])); return new PostResource($post); } |
This method receives a request and a post id as parameters, then we use route model binding to resolve the id into an instance of a Post. First, we validate the $request attributes, then we update the title and body fields of the resolved post.
BUILDING THE HANDLER ACTION FOR THE DELETE OPERATION
Let’s update the destroy() method in the PostController class. This method will allow us to remove an existing post:
|
|
// File: ./app/Http/Controllers/PostController.php public function destroy(Post $post) { $post->delete(); return response()->json(null, 204); } |
In this method, we resolve the Post instance, then delete it and return a 204 response code.
Our methods are complete. We have a method to handle our CRUD operations.
We are not done with the dashboard just yet. In the next part, we will add the views that lets us create and update posts.
we have built the first parts of the admin dashboard using Vue. We also made it into an SPA with the VueRouter, this means that visiting the pages does not cause a reload to the web browser.
We only built the wrapper component and the Read component that retrieves the posts to be loaded so an admin can manage them.
we will build the view that will allow users to create and update posts. We will start writing code in the Update.vue and Create.vue files that we created in the previous article.
When we are done with this part, we will have additional functionalities like create and updating.
INCLUDING THE NEW ROUTES IN VUE ROUTER
In the previous article, we only defined the route for the Read component, we need to include the route configuration for the new components that we are about to build; Update and Create.
Open the resources/js/app.js file and replace the contents with the code below:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30
|
import Homepage from './components/Homepage' import Create from './components/Create' import Read from './components/Read' import Update from './components/Update' Vue.use(VueRouter) const router = new VueRouter({ mode: 'history', routes: [ { path: '/admin/dashboard', name: 'read', component: Read, props: true }, { path: '/admin/create', name: 'create', component: Create, props: true }, { path: '/admin/update', name: 'update', component: Update, props: true }, ], }); |
Above, we have added two new components to the JavaScript file. We have the Create and Read components. We also added them to the router so that they can be loaded using the specified URLs.
BUILDING THE CREATE VIEW
Open the Create.vue file and update it with this markup template:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38
|
<!-- File: ./resources/js/components/Create.vue --> <template> <div class="container"> <form> <div :class="['form-group m-1 p-3', (successful ? 'alert-success' : '')]"> <span v-if="successful" class="label label-sucess">Published!</span> </div> <div :class="['form-group m-1 p-3', error ? 'alert-danger' : '']"> <span v-if="errors.title" class="label label-danger"> {{ errors.title[0] }} </span> <span v-if="errors.body" class="label label-danger"> {{ errors.body[0] }} </span> <span v-if="errors.image" class="label label-danger"> {{ errors.image[0] }} </span> </div> <div class="form-group"> <input type="title" ref="title" class="form-control" id="title" placeholder="Enter title" required> </div> <div class="form-group"> <textarea class="form-control" ref="body" id="body" placeholder="Enter a body" rows="8" required></textarea> </div> <div class="custom-file mb-3"> <input type="file" ref="image" name="image" class="custom-file-input" id="image" required> <label class="custom-file-label" >Choose file...</label> </div> <button type="submit" @click.prevent="create" class="btn btn-primary block"> Submit </button> </form> </div> </template> |
Above we have the template for the Create component. If there is an error during post creation, there will be a field indicating the specific error. When a post is successfully published, there will also a message saying it was successful.
Let’s include the script logic that will perform the sending of posts to our backend server and read back the response.
After the closing template tag add this:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46
|
<script> export default { props: { userId: { type: Number, required: true } }, data() { return { error: false, successful: false, errors: [] }; }, methods: { create() { const formData = new FormData(); formData.append("title", this.$refs.title.value); formData.append("body", this.$refs.body.value); formData.append("user_id", this.userId); formData.append("image", this.$refs.image.files[0]); axios .post("/api/posts", formData) .then(response => { this.successful = true; this.error = false; this.errors = []; }) .catch(error => { if (!_.isEmpty(error.response)) { if ((error.response.status = 422)) { this.errors = error.response.data.errors; this.successful = false; this.error = true; } } }); this.$refs.title.value = ""; this.$refs.body.value = ""; } } }; </script> |
In the script above, we defined a create() method that takes the values of the input fields and uses the Axios library to send them to the API interface on the backend server. Within this method, we also update the status of the operation, so that an admin user can know when a post is created successfully or not.
BUILDING THE UPDATE VIEW
Let’s start building the Update component. Open the Update.vue file and update it with this markup template:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31
|
<!-- File: ./resources/js/components/Update.vue --> <template> <div class="container"> <form> <div :class="['form-group m-1 p-3', successful ? 'alert-success' : '']"> <span v-if="successful" class="label label-sucess">Updated!</span> </div> <div :class="['form-group m-1 p-3', error ? 'alert-danger' : '']"> <span v-if="errors.title" class="label label-danger"> {{ errors.title[0] }} </span> <span v-if="errors.body" class="label label-danger"> {{ errors.body[0] }} </span> </div> <div class="form-group"> <input type="title" ref="title" class="form-control" id="title" placeholder="Enter title" required> </div> <div class="form-group"> <textarea class="form-control" ref="body" id="body" placeholder="Enter a body" rows="8" required></textarea> </div> <button type="submit" @click.prevent="update" class="btn btn-primary block"> Submit </button> </form> </div> </template> |
This template is similar to the one in the Create component. Let’s add the script for the component.
Below the closing template tag, paste the following:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49
|
<script> export default { mounted() { this.getPost(); }, props: { postId: { type: Number, required: true } }, data() { return { error: false, successful: false, errors: [] }; }, methods: { update() { let title = this.$refs.title.value; let body = this.$refs.body.value; axios .put("/api/posts/" + this.postId, { title, body }) .then(response => { this.successful = true; this.error = false; this.errors = []; }) .catch(error => { if (!_.isEmpty(error.response)) { if ((error.response.status = 422)) { this.errors = error.response.data.errors; this.successful = false; this.error = true; } } }); }, getPost() { axios.get("/api/posts/" + this.postId).then(response => { this.$refs.title.value = response.data.data.title; this.$refs.body.value = response.data.data.body; }); } } }; </script> |
In the script above, we make a call to the getPosts() method as soon as the component is mounted. The getPosts() method fetches the data of a single post from the backend server, using the postId.
When Axios sends back the data for the post, we update the input fields in this component so they can be updated.
Finally, the update() method takes the values of the fields in the components and attempts to send them to the backend server for an update. In a situation where the fails, we get instant feedback.
TESTING THE APPLICATION
To test that our changes work, we want to refresh the database and restore it back to a fresh state. To do this, run the following command in your terminal:
|
|
$ php artisan migrate:fresh --seed |
Next, let’s compile our JavaScript files and assets. This will make sure all the changes we made in the Vue component and the app.js file gets built. To recompile, run the command below in your terminal:
Lastly, we need to serve the application. To do this, run the following command in your terminal window:
If you had the serve command running before, then you might need to restart it.
We will visit the application’s http://localhost:8000 and log in as an admin user. From the dashboard, you can test the create and update feature
Now, we will be adding support for comments. We will also ensure that the comments on each post are updated in realtime, so a user doesn’t have to refresh the page to see new comments.
ADDING COMMENTS TO THE BACKEND
When we were creating the API, we did not add the support for comments to the post resource, so we will have to do so now. Open the API project in your text editor as we will be modifying the project a little.
The first thing we want to do is create a model, controller, and a migration for the comment resource. To do this, open your terminal and cd to the project directory and run the following command:
|
|
teddy@teddy:~/Documents/works/laravel/laravel-cms$ php artisan make:model Comment -mc Model created successfully. Created Migration: 2019_01_30_042953_create_comments_table Controller created successfully. |
The command above will create a model called Comment, a controller called CommentController, and a migration file in the database/migrations directory.
UPDATING THE COMMENTS MIGRATION FILE
To update the comments migration navigate to the database/migrations folder and find the newly created migration file for the Comment model. Let’s update the up() method in the file:
|
|
//database/migrations/2019_01_30_042953_create_comments_table.php public function up() { Schema::create('comments', function (Blueprint $table) { $table->increments('id'); $table->timestamps(); $table->integer('user_id')->unsigned(); $table->integer('post_id')->unsigned(); $table->text('body'); }); } |
We included user_id and post_id fields because we intend to create a link between the comments, users, and posts. The body field will contain the actual comment.
DEFINING THE RELATIONSHIPS AMONG THE COMMENT, USER, AND POST MODELS
In this application, a comment will belong to a user and a post because a user can make a comment on a specific post, so we need to define the relationship that ties everything up.
Open the User model and include this method:
|
|
// File: ./app/User.php public function comments() { return $this->hasMany(Comment::class); } |
This is a relationship that simply says that a user can have many comments. Now let’s define the same relationship on the Post model. Open the Post.php file and include this method:
|
|
// File: ./app/Post.php public function comments() { return $this->hasMany(Comment::class); } |
Finally, we will include two methods in the Comment model to complete the second half of the relationships we defined in the User and Post models.
Open the app/Comment.php file and include these methods:
|
|
// File: ./app/Comment.php public function user() { return $this->belongsTo(User::class); } public function post() { return $this->belongsTo(Post::class); } |
Since we want to be able to mass assign data to specific fields of a comment instance during comment creation, we will include this array of permitted assignments in the app/Comment.php file:
|
|
protected $fillable = ['user_id', 'post_id', 'body']; |
We can now run our database migration for our comments:
|
|
teddy@teddy:~/Documents/works/laravel/laravel-cms$ php artisan migrate Migrating: 2019_01_30_042953_create_comments_table Migrated: 2019_01_30_042953_create_comments_table |
CONFIGURING LARAVEL TO BROADCAST EVENTS USING PUSHER
We already said that the comments will have a realtime functionality and we will be building this using Pusher, so we need to enable Laravel’s event broadcasting feature.
Open the config/app.php file and uncomment the following line in the providers array:
|
|
App\Providers\BroadcastServiceProvider, |
Next, we need to configure the broadcast driver in the .env file. Replace from BROADCAST_DRIVER=log TO
Let’s pull in the Pusher PHP SDK using composer:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21
|
teddy@teddy:~/Documents/works/laravel/laravel-cms$ composer require pusher/pusher-php-server Using version ^3.3 for pusher/pusher-php-server ./composer.json has been updated Loading composer repositories with package information Updating dependencies (including require-dev) Package operations: 2 installs, 0 updates, 0 removals - Installing paragonie/sodium_compat (v1.8.1): Downloading (100%) - Installing pusher/pusher-php-server (v3.3.1): Downloading (100%) paragonie/sodium_compat suggests installing ext-libsodium (PHP < 7.0: Better performance, password hashing (Argon2i), secure memory management (memzero), and better security.) Writing lock file Generating optimized autoload files > Illuminate\Foundation\ComposerScripts::postAutoloadDump > @php artisan package:discover --ansi Discovered Package: beyondcode/laravel-dump-server Discovered Package: fideloper/proxy Discovered Package: laravel/nexmo-notification-channel Discovered Package: laravel/slack-notification-channel Discovered Package: laravel/tinker Discovered Package: nesbot/carbon Discovered Package: nunomaduro/collision Package manifest generated successfully. |
CONFIGURING PUSHER
For us to use Pusher in this application, it is a prerequisite that you have a Pusher account. You can create a free Pusher account here then login to your dashboard and create an app.
Once you have created an app, we will use the app details to configure pusher in the .env file:
|
|
PUSHER_APP_ID=xxxxxx PUSHER_APP_KEY=xxxxxxxxxxxxxxxxxxxx PUSHER_APP_SECRET=xxxxxxxxxxxxxxxxxxxx PUSHER_APP_CLUSTER=xx |
SORRY, ONLY ADMIN CAN SHOW THIS!
Update the Pusher keys with the app credentials provided for you under the Keys section on the Overview tab on the Pusher dashboard.
BROADCASTING AN EVENT FOR WHEN A NEW COMMENT IS SENT
To make the comment update realtime, we have to broadcast an event based on the comment creation activity. We will create a new event and call it CommentSent. It is to be fired when there is a successful creation of a new comment.
Run command in your terminal:
|
|
teddy@teddy:~/Documents/works/laravel/laravel-cms$ php artisan make:event CommentSent Event created successfully. |
There will be a newly created file in the app\Events directory, open the CommentSent.php file and ensure that it implements the ShouldBroadcast interface.
Open and replace the file with the following code:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35
|
// File: ./app/Events/CommentSent.php <?php namespace App\Events; use App\Comment; use App\User; use Illuminate\Broadcasting\Channel; use Illuminate\Queue\SerializesModels; use Illuminate\Broadcasting\PrivateChannel; use Illuminate\Broadcasting\PresenceChannel; use Illuminate\Foundation\Events\Dispatchable; use Illuminate\Broadcasting\InteractsWithSockets; use Illuminate\Contracts\Broadcasting\ShouldBroadcast; class CommentSent implements ShouldBroadcast { use Dispatchable, InteractsWithSockets, SerializesModels; public $user; public $comment; public function __construct(User $user, Comment $comment) { $this->user = $user; $this->comment = $comment; } public function broadcastOn() { return new PrivateChannel('comment'); } } |
In the code above, we created two public properties, user and comment, to hold the data that will be passed to the channel we are broadcasting on. We also created a private channel called comment. We are using a private channel so that only authenticated clients can subscribe to the channel.
NOTE: Make sure ‘CommentSent’ class implements ‘ShouldBroadcast’ class. When I accidentally missing to implement it, the message can’t be broadcasted at real time properly. I need to refresh the page to see the incoming message on the another login.
DEFINING THE ROUTES FOR HANDLING OPERATIONS ON A COMMENT
We created a controller for the comment model earlier but we haven’t defined the web routes that will redirect requests to be handled by that controller.
Open the routes/web.php file and include the code below:
|
|
// File: ./routes/web.php Route::get('/{post}/comments', 'CommentController@index'); Route::post('/{post}/comments', 'CommentController@store'); |
SETTING UP THE ACTION METHODS IN THE COMMENTCONTROLLER
We need to include two methods in the CommentController.php file, these methods will be responsible for storing and retrieving methods. In the store() method, we will also be broadcasting an event when a new comment is created.
Open the CommentController.php file and replace its contents with the code below:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36
|
// File: ./app/Http/Controllers/CommentController.php <?php namespace App\Http\Controllers; use App\Comment; use App\Events\CommentSent; use App\Post; use Illuminate\Http\Request; class CommentController extends Controller { public function store(Post $post) { $this->validate(request(), [ 'body' => 'required', ]); $user = auth()->user(); $comment = Comment::create([ 'user_id' => $user->id, 'post_id' => $post->id, 'body' => request('body'), ]); broadcast(new CommentSent($user, $comment))->toOthers(); return ['status' => 'Message Sent!']; } public function index(Post $post) { return $post->comments()->with('user')->get(); } } |
In the store method above, we are validating then creating a new post comment. After the comment has been created, we broadcast the CommentSent event to other clients so they can update their comments list in realtime.
In the index method we just return the comments belonging to a post along with the user that made the comment.
ADDING A LAYER OF AUTHENTICATION
Let’s add a layer of authentication that ensures that only authenticated users can listen on the private comment channel we created.
Add the following code to the routes/channels.php file:
|
|
// File: ./routes/channels.php Broadcast::channel('comment', function ($user) { return auth()->check(); }); |
ADDING COMMENTS TO THE FRONTEND
In the second article of this series, we created the view for the single post landing page in the single.blade.php file, but we didn’t add the comments functionality. We are going to add it now. We will be using Vue to build the comments for this application so the first thing we will do is to include Vue in the frontend of our application.
Open the master layout template and include Vue to its <head> tag. Just before the <title> tag appears in the master.blade.php file, include this snippet:
|
|
<!-- File: ./resources/views/layouts/master.blade.php --> <meta name="csrf-token" content="{{ csrf_token() }}"> <script src="{{ asset('js/app.js') }}" defer></script> |
The csrf_token() is there so that users cannot forge requests in our application. All our requests will pick the randomly generated csrf-token and use that to make requests.
Related: CSRF in Laravel: how VerifyCsrfToken works and how to prevent attacks
Now the next thing we want to do is update the resources/js/app.js file so that it includes a template for the comments view.
Open the file and replace its contents with the code below:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
|
//resources/js/app.js require('./bootstrap'); import Vue from 'vue' ... import Comments from './components/Comments' Vue.use(VueRouter) const router = new VueRouter({ ... }); const app = new Vue({ el: '#app', components: { Homepage, Comments }, router, }); |
Above we imported the Comment component and then we added it to the list of components in the applications Vue instance.
Now create a Comments.vue file in the resources/js/components directory. This is where all the code for our comment view will go. We will populate this file later on.
INSTALLING PUSHER AND LARAVEL ECHO
For us to be able to use Pusher and subscribe to events on the frontend, we need to pull in both Pusher and Laravel Echo. We will do so by running this command:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26
|
teddy@teddy:~/Documents/works/laravel/laravel-cms$ npm install --save laravel-echo pusher-js npm WARN rm not removing /home/teddy/Documents/works/laravel/laravel-cms/node_modules/.bin/regjsparser as it wasn't installed by /home/teddy/Documents/works/laravel/laravel-cms/node_modules/regjsparser npm WARN rm not removing /home/teddy/Documents/works/laravel/laravel-cms/node_modules/.bin/json5 as it wasn't installed by /home/teddy/Documents/works/laravel/laravel-cms/node_modules/json5 npm WARN rm not removing /home/teddy/Documents/works/laravel/laravel-cms/node_modules/.bin/jsesc as it wasn't installed by /home/teddy/Documents/works/laravel/laravel-cms/node_modules/jsesc npm WARN rm not removing /home/teddy/Documents/works/laravel/laravel-cms/node_modules/.bin/cssesc as it wasn't installed by /home/teddy/Documents/works/laravel/laravel-cms/node_modules/cssesc > webpack-cli@3.2.1 postinstall /home/teddy/Documents/works/laravel/laravel-cms/node_modules/webpack-cli > lightercollective *** Thank you for using webpack-cli! *** Please consider donating to our open collective to help us maintain this package. https://opencollective.com/webpack/donate *** npm WARN optional SKIPPING OPTIONAL DEPENDENCY: fsevents@1.2.7 (node_modules/fsevents): npm WARN notsup SKIPPING OPTIONAL DEPENDENCY: Unsupported platform for fsevents@1.2.7: wanted {"os":"darwin","arch":"any"} (current: {"os":"linux","arch":"x64"}) + laravel-echo@1.5.2 + pusher-js@4.3.1 added 87 packages from 71 contributors, removed 51 packages, updated 852 packages and audited 16870 packages in 12.86s found 0 vulnerabilities |
Laravel Echo is a JavaScript library that makes it easy to subscribe to channels and listen for events broadcast by Laravel.
Now let’s configure Laravel Echo to work in our application. In the resources/js/bootstrap.js file, find and uncomment this snippet of code:
|
|
//resources/js/bootstrap.js import Echo from 'laravel-echo' window.Pusher = require('pusher-js'); window.Echo = new Echo({ broadcaster: 'pusher', key: process.env.MIX_PUSHER_APP_KEY, cluster: process.env.MIX_PUSHER_APP_CLUSTER, encrypted: true }); |
The key and cluster will pull the keys from your .env file so no need to enter them manually again.
Now let’s import the Comments component into the single.blade.php file and pass along the required the props.
Open the single.blade.php file and replace its contents with the code below:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41
|
{{-- File: ./resources/views/single.blade.php --}} @extends('layouts.master') @section('content') <div class="container"> <div class="row"> <div class="col-lg-10 mx-auto"> <h3 class="mt-4">{{ $post->title }} <span class="lead"> by <a href="#"> {{ $post->user->name }} </a></span> </h3> <hr> <p>Posted {{ $post->created_at->diffForHumans() }} </p> <hr> <img class="img-fluid rounded" src=" {!! !empty($post->image) ? '/uploads/posts/' . $post->image : 'http://placehold.it/750x300' !!} " alt=""> <hr> <div> <p>{{ $post->body }}</p> <hr> <br> </div> <!--<div class="card my-4"> <h5 class="card-header">Leave a Comment:</h5> <div class="card-body"> <form> <div class="form-group"> <textarea class="form-control" rows="3"></textarea> </div> <button type="submit" class="btn btn-primary">Submit</button> </form> </div> </div>--> @auth <Comments :post-id = '@json($post->id)' :user-name = '@json(auth()->user()->name)'> </Comments> @endauth </div> </div> </div> @endsection |
BUILDING THE COMMENTS VIEW
Open the Comments.vue file and add the following markup template below:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
|
<!-- File: ./resources/js/components/Comments.vue --> <template> <div class="card my-4"> <h5 class="card-header">Leave a Comment:</h5> <div class="card-body"> <form> <div class="form-group"> <textarea ref="body" class="form-control" rows="3"></textarea> </div> <button type="submit" @click.prevent="addComment" class="btn btn-primary"> Submit </button> </form> </div> <p class="border p-3" v-for="comment in comments"> <strong>{{ comment.user.name }}</strong>: <span>{{ comment.body }}</span> </p> </div> </template> |
Now, we’ll add a script that defines two methods:
fetchComments() – this will fetch all the existing comments when the component is created.
addComment() – this will add a new comment by hitting the backend server. It will also trigger a new event that will be broadcast so all clients receive them in realtime.
In the same file, add the following below the closing template tag:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45
|
<!-- File: ./resources/js/components/Comments.vue --> <template> ... </template> <script> export default { props: { userName: { type: String, required: true }, postId: { type: Number, required: true } }, data() { return { comments: [] }; }, created() { this.fetchComments(); }, methods: { fetchComments() { axios.get("/" + this.postId + "/comments").then(response => { this.comments = response.data; }); }, addComment() { let body = this.$refs.body.value; axios.post("/" + this.postId + "/comments", { body }).then(response => { this.comments.push({ user: {name: this.userName}, body: this.$refs.body.value }); this.$refs.body.value = ''; }); } }, } </script> |
In the created() method above, we first made a call to the fetchComments() method, then we created a listener to the private comment channel using Laravel Echo. Once this listener is triggered, the comments property is updated.
TESTING THE APPLICATION
Now let’s test the application to see if it is working as intended. Before running the application, we need to refresh our database so as to revert any changes. To do this, run the command below in your terminal:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
|
teddy@teddy:~/Documents/works/laravel/laravel-cms$ php artisan migrate:fresh --seed Dropped all tables successfully. Migration table created successfully. Migrating: 2014_10_12_000000_create_users_table Migrated: 2014_10_12_000000_create_users_table Migrating: 2014_10_12_100000_create_password_resets_table Migrated: 2014_10_12_100000_create_password_resets_table Migrating: 2019_01_21_031440_create_roles_table Migrated: 2019_01_21_031440_create_roles_table Migrating: 2019_01_21_032646_create_role_user_table Migrated: 2019_01_21_032646_create_role_user_table Migrating: 2019_01_21_083457_create_posts_table Migrated: 2019_01_21_083457_create_posts_table Migrating: 2019_01_30_042953_create_comments_table Migrated: 2019_01_30_042953_create_comments_table Seeding: RoleTableSeeder Seeding: UserTableSeeder Seeding: PostTableSeeder Database seeding completed successfully. |
Next, let’s build the application so that all the changes will be compiled and included as a part of the JavaScript file. To do this, run the following command on your terminal:
Finally, let’s serve the application using this command:
To test that our application works visit the application URL http://localhost:8000 on two separate browser windows, we will log in to our application on each of the windows as a different user.
We will finally make a comment on the same post on each of the browser windows and check that it updates in realtime on the other window:
IMPROVEMENTS:
Currently the comments can only be displayed if the user is logged in. But I want to display them also if no user is logged in but it only read the comments and can’t add the comment. So modify resources/views/single.blade.php:
|
|
@auth <Comments :post-id='@json($post->id)' :user-name='@json(auth()->user()->name)'> </Comments> @else <Comments :post-id='@json($post->id)' :user-name='@json("")'> </Comments> @endauth |
Make user-name variable is empty string. Then modify resources/js/components/Comments.vue on the template and the script:
|
|
<template> <div class="card my-4"> <h5 class="card-header" v-if="userName"> Leave a comment: </h5> <h5 class="card-header" v-else> Comments: (<a href="/login">Login</a> to add comment) </h5> <div class="card-body" v-if="userName"> ... </div> <p class="border p-3" v-for="comment in comments"> ... </p> </div> </template> |
On the template, show the text input if ‘userName’ is exist. Otherwise just show the login link. Then on the script, get the broadcast only if the ‘userName’ is exist:
|
|
created() { this.fetchComments(); if(this.userName){ Echo.private("comment").listen("CommentSent", e => { ... }); } }, |
Anyway if the user not logged in, they can’t get the real time message!
How to show the message counter?
Modify the template and the script in resources/js/components/Comments.vue. On the script, add a new computed variable ‘commentCount’ in ‘data’. Why not in ‘props’? because the variables in ‘data’ can be changed real time. not only on the page load.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39
|
data() { return { comments: [], commentCount: 0 }; }, created() { ... if(this.userName){ Echo.private("comment").listen("CommentSent", e => { this.comments.push({ ... }); this.commentCount = this.comments.length; }); } }, methods: { fetchComments() { axios.get("/" + this.postId + "/comments").then(response => { this.comments = response.data; this.commentCount = response.data.length; }); }, addComment() { ... axios.post("/" + this.postId + "/comments", { body }).then(response => { this.comments.push({ ... }); this.commentCount = this.comments.length; this.$refs.body.value = ''; }); } }, |
‘commentCount’ is calculated in ‘addComment’ method when the new comment is inserted. Also in ‘fetchComments’ when the page is loaded. To calculate it at real time, also put it in ‘created’. Then show the counter on the template:
|
|
<h5 class="card-header" v-if="userName"> There are {{ commentCount }} comments. Leave a comment: </h5> <h5 class="card-header" v-else> There are {{ commentCount }} comments. <a href="/login">Login</a> to add comment. </h5> |
So the message counter would appear on the post page (single page). But how to show the counter on the homepage?
So modify resources/views/landing.blade.php to show the counter:
|
|
... <div class="card-body"> ... <a href="/posts/{{ $post->id }}" class="btn btn-primary">Read More →</a> <a href="/posts/{{ $post->id }}#comments" class="btn btn-primary">Comments ({{ $post->comments()->count() }})</a> </div> ... |
give the bookmark link (#) to jump straight to the comments section. Modify resources/js/components/Comments.vue to add the bookmark ‘comments’:
|
|
<template> <div id="comments" class="card my-4"> ... |