How to build Laravel packages

Introduction

In this tutorial, we will learn how to build a Laravel package with Vue.js and Pusher. The sample package we will be building will be a review system that can be added to a page you are building independently.

This package will ensure that the reviews on each page are updated in realtime so that visitors don’t have to refresh the page to see new reviews.

When we are done, our app will look like this:

Prerequisites

To follow along with this article, you need the following:

  • A Pusher account. Sign up here if you don’t have one.
  • Laravel installed on your machine. We are working with Laravel 5.8.
  • Basic knowledge of the Laravel framework.
  • Basic knowledge of JavaScript.
  • Basic knowledge of Vue.js.

Let’s get started.

Setup the Laravel project

The first thing we need to do is create a Laravel project. We will use the Laravel installer to make this possible. Open your terminal, and run this command:

    $ laravel new sampleLaravelApp

This will create a Laravel application named sampleLaravelApp. Next, we will install a Composer package called laravel-packager. We need this to build our Laravel package. In the root directory of the Laravel project run this command:

    $ composer require jeroen-g/laravel-packager --dev

Now we have installed the package. We can use it to scaffold our own package. Run this command in your projects directory:

    $ php artisan packager:new Acme PageReview --i

The package will be created interactively. It will interactively ask some questions and your responses will be used to create a composer.json file for our package.

Now you should have a folder named packages inside your Laravel project. Inside the packages folder, you will find a directory with your vendor name (Acme in our case), a subfolder representing the package name (PageReview).

Building the Laravel package

In this section, we will start customizing the generated package files with the actual code and add some new files where necessary. Before adding logic, note that when we were setting up the new Laravel package, laravel-packager enabled Package Discovery for us.

This is a Laravel feature that automatically loads a package’s service provider. In your package’s composer.json file, this is the snippet that enables it:

1// File: ./packages/Acme/PageReview/composer.json
2    "extra": {
3      "laravel": {
4        "providers": [
5          "Acme\\PageReview\\PageReviewServiceProvider"
6        ],
7        "aliases": {
8          "PageReview": "Acme\\review\\Facades\\PageReview"
9        }
10      }
11    }

Adding our database migration file

Next, let’s create a migration file to help us save reviews to the database. Create a new folder named database in the packages/Acme/PageReview directory. Inside this folder, create a migrations folder.

Run this command in the Laravel project root directory to create the package migration:

    $ php artisan make:migration create_page_reviews_tables --path=packages/Acme/PageReview/database/migrations

Update the up() and down()method of the migration file as seen below:

1// File: packages/Acme/PageReview/database/migrations/*_create_page_reviews_table.php
2    
3    public function up()
4    {
5        Schema::create('pages', function (Blueprint $table) {
6            $table->increments('id');
7            $table->text('path');
8            $table->timestamps();
9        });
10    
11        Schema::create('reviews', function (Blueprint $table) {
12            $table->increments('id');
13            $table->text('page_id');
14            $table->text('username');
15            $table->text('comment');
16            $table->timestamps();
17        });
18    }
19    
20    public function down()
21    {
22        Schema::dropIfExists('pages');
23        Schema::dropIfExists('reviews');
24    }

NOTE: It is not recommended to create one migration for two tables but for brevity we will. However, in a real application, create one migration per database operation.

In the up method, we have create two tables, the pages and reviews table. The pages table will save the URL path visited in our app. The reviews table will contain the username and comment field for the actual review. We included page_id field because we intend to create a link between the reviews and pages. The down method will drop both tables when triggered.

Adding the package model files

In our packages/Acme/PageReview directory open the src directory and create a new folder called Models. Inside the folder, create two new files called Review.php and Page.php.

Open the Review.php and paste this snippet:

1<?php
2    // File: packages/Acme/PageReview/src/Models/Review.php
3    
4    namespace Acme\PageReview\Models;
5    
6    use Illuminate\Database\Eloquent\Model;
7    
8    class Review extends Model
9    {
10        protected $fillable = [
11            'page_id', 
12            'username', 
13            'comment'
14        ];
15    }

Next, open the Page.php and paste this snippet:

1<?php
2    // File: packages/Acme/PageReview/src/Models/Page.php
3    
4    namespace Acme\PageReview\Models;
5    
6    use Illuminate\Database\Eloquent\Model;
7    
8    class Page extends Model
9    {
10        protected $fillable = [
11          'path',
12        ];
13    
14        public function reviews()
15        {
16            return $this->hasMany(Review::class);
17        }
18    }

In the code above, the Page model has a reviews method, which forms a hasMany relationship with the Review model. This simply means a page can have many reviews.

Updating the package config file

We already have a config folder inside our package directory. Inside this config folder there is a single file representing our package configurations, where anyone using this package can easily override the default configuration options. The name of the file is pagereview.php.

Update the pagereview.php file like so:

1<?php
2    // File: packages/Acme/PageReview/config/pagereview.php
3    
4    return [
5        /*
6        * This determines how the reviews will be ordered when fetched
7        */
8        'order' => [
9            'by' => 'DESC',
10            'as' => 'created_at',
11        ],
12    ];

Setting the package controller methods for certain actions

Let’s create a new folder Controllers inside the packages/Acme/PageReview/src directory. Inside the folder, create a file called PageReviewController.php and paste this snippet there:

1<?php
2    // File: packages/Acme/PageReview/src/Controllers/PageReviewController.php
3    
4    namespace Acme\PageReview\Controllers;
5    
6    use Illuminate\Http\Request;
7    use Acme\PageReview\Models\Page;
8    use Illuminate\Routing\Controller;
9    use Pusher\Laravel\Facades\Pusher;
10    
11    class PageReviewController extends Controller 
12    {
13        public function index(Request $request)
14        {
15            if (isset($request->path)) {
16              $page = Page::firstorCreate(['path' => $request->path]);
17      
18              $reviews = $page->reviews()
19                      ->orderBy(
20                          config('pagereview.order.as'),
21                          config('pagereview.order.by')
22                      )
23                      ->get();
24      
25              return response()->json([
26                  'page' => $page,
27                  'reviews' => $reviews
28              ]);
29            }
30              
31            return response()->json([]);
32        }
33    
34        public function store(Request $request)
35        {
36            $page = Page::firstorCreate(['path' => $request->path]);
37    
38            $review = $page->reviews()->create([
39              'username' => $request->username,
40              'comment' => $request->comment,
41            ]);
42    
43            Pusher::trigger('page-'.$page->id, 'new-review', $review);
44    
45            return $review;
46        }
47    }

This controller file has two methods:

  • index: here we get a path and save it if it is not already saved, then we get the related reviews to that page and return both the page and reviews as a JSON response.
  • store: here we simply save the page, and review. We also trigger a message to the Pusher API so that channels listening can pick up the review.

Next, let's add the Pusher package as a dependency. Open the composer.json in the package and add it like so:

1// File: packages/Acme/PageReview/composer.json
2    "require": {
3        // ...
4        "pusher/pusher-http-laravel": "^4.2"
5    },

Defining routes

Next, we will create a routes folder in the packages/Acme/PageReview directory to save our routes. After creating the folder, create a web.php file inside the folder and update it with the snippet below:

1<?php
2    // File: packages/Acme/PageReview/routes/web.php
3    Route::get('pagereview', 'PageReviewController@index')->name('pagereview.index');
4    Route::post('pagereview', 'PageReviewController@store')->name('pagereview.store');

Updating the service provider

Most Laravel packages come with migrations, routes, config files or views. To be able to use these resources we have to load them in our package’s service provider.

You can read more about that in the official documentation

Update the PageReviewServiceProvider file like so:

1<?php
2    // packages/Acme/PageReview/src/PageReviewServiceProvider.php
3    
4    namespace Acme\PageReview;
5    
6    use Illuminate\Support\ServiceProvider;
7    
8    class PageReviewServiceProvider extends ServiceProvider
9    {
10        /**
11         * Perform post-registration booting of services.
12         *
13         * @return void
14         */
15        public function boot()
16        {
17            $this->loadMigrationsFrom(__DIR__ . '/../database/migrations');
18            $this->loadViewsFrom(__DIR__.'/../resources/views', 'pagereview');
19    
20            $this->app['router']->namespace('Acme\\PageReview\\Controllers')
21                ->middleware(['web'])
22                ->group(function () {
23                    $this->loadRoutesFrom(__DIR__ . '/../routes/web.php');
24                });
25    
26            if ($this->app->runningInConsole()) {
27                $this->bootForConsole();
28            }
29        }
30    
31        /**
32         * Register any package services.
33         *
34         * @return void
35         */
36        public function register()
37        {
38            $this->mergeConfigFrom(__DIR__.'/../config/pagereview.php', 'pagereview');
39        }
40    
41        /**
42         * Get the services provided by the provider.
43         *
44         * @return array
45         */
46        public function provides()
47        {
48            return ['pagereview'];
49        }
50    
51        /**
52         * Console-specific booting.
53         *
54         * @return void
55         */
56        protected function bootForConsole()
57        {
58            $this->publishes([
59                __DIR__.'/../config/pagereview.php' => config_path('pagereview.php'),
60            ], 'pagereview.config');
61    
62            $this->publishes([
63                __DIR__.'/../resources/views' => base_path('resources/views/vendor/acme'),
64            ], 'pagereview.views');
65    
66            $this->publishes([
67                __DIR__ . '/../database/migrations/' => database_path('migrations'),
68            ], 'migrations');
69        }
70    }

The boot method loads resources such as migration, routes, or any other piece of functionality while the register method only binds logic to the service container. We also have a bootForConsole method meant to publish resource files such as views and migrations if it is loaded via the console.

Building the frontend

Now, we will create the frontend view for the package’s review section. It will be a simple UI that displays a form to add a review and also display a list of reviews with the author name.

Create a resources/views folder in the packages/Acme/PageReview directory and create a file called section.blade.php. Paste this snippet to the file:

1<!-- File: packages/Acme/PageReview/resources/views/section.blade.php -->
2    <div class="container" id="review">
3        <div class="row justify-content-center">
4            <div class="col-md-8">
5                <div class="card">
6                    <div class="card-body">
7                        <h4>Add review</h4>
8                        <div class="form-group">
9                            <input type="text" v-model="username" class="form-control col-4" placeholder="Enter your username">
10                        </div>
11                        <div class="form-group">
12                            <textarea class="form-control" v-model="comment" placeholder="Enter your review"></textarea>
13                        </div>
14                        <div class="form-group">
15                            <input type="button" v-on:click="addPageReview" v-bind:disabled="isDisabled" class="btn btn-success" value="Add Review" />
16                        </div>
17                        <hr />
18                        <h4>Display reviews</h4>
19                        <div v-for="review in reviews">
20                            <strong>@{{ review.username }}</strong>
21                            <p>@{{ review.comment }}</p>
22                        </div>
23                    </div>
24                </div>
25            </div>
26        </div>
27    </div>

In the code above we added a few attributes to some elements:

  • Both input fields have a v-model Vue directive allowing us to create a two-way binding.
  • The Add Review button has a v-on:click to intercept any DOM event by and triggers the addPageReview method on the Vue instance.
  • The v-for attribute enables us to iterate over the reviews data and display each review belonging to that page.

Still in the same file, add the following below the closing div tag:

1<!-- File: packages/vendor-name/PageReview/resources/views/section.blade.php -->
2    <!-- Add Vue Code -->
3    <script>
4        Pusher.logToConsole = true;
5        var review = new Vue({
6            el: '#review',
7            data: {
8                username: null,
9                comment: null,
10                path: window.location.pathname,
11                isDisabled: false,
12                reviews: [],
13                page: [],
14            },
15            methods: {
16                subscribe() {
17                    var pusher = new Pusher('{{ env('PUSHER_APP_KEY')}}', {
18                        cluster: '{{ env('PUSHER_APP_CLUSTER') }}',
19                    });
20                    pusher.subscribe('page-' + this.page.id)
21                        .bind('new-review', this.fetchPageReviews);
22                },
23                fetchPageReviews() {
24                    var vm = this;
25                    var url = '{{ route('pagereview.index') }}' + '?path=' + this.path;
26    
27                    fetch(url)
28                      .then(function(response) {
29                          return response.json()
30                      })
31                      .then(function(json) {
32                          vm.page = json.page
33                          vm.reviews = json.reviews
34                          vm.subscribe();
35                      })
36                },
37                addPageReview(event) {
38                    event.preventDefault();
39                    this.isDisabled = true;
40                    const token = document.head.querySelector('meta[name="csrf-token"]');
41                    const data = {
42                        path: this.path,
43                        comment: this.comment,
44                        username: this.username,
45                    };
46                    fetch('{{ route('pagereview.store') }}', {
47                        body: JSON.stringify(data),
48                        credentials: 'same-origin',
49                        headers: {
50                            'content-type': 'application/json',
51                            'x-csrf-token': token.content,
52                        },
53                        method: 'POST',
54                        mode: 'cors',
55                    }).then(response => {
56                        this.isDisabled = false;
57                        if (response.ok) {
58                            this.username = '';
59                            this.comment = '';
60                            this.fetchPageReviews();
61                        }
62                    })
63                },            
64            },
65            created() {
66                this.fetchPageReviews();
67            }
68        });
69    </script>

Here, we have methods to fetch reviews and add reviews on a page. Our Vue instance is instantiated on the review ID and has some data values.

We also defined three methods:

  • subscribe - this subscribes to a new-review event on a Pusher channel on the current page for realtime update.
  • fetchPageReviews - this method will fetch all the existing reviews for this page when the loaded and also calls the subscribe method.
  • addPageReview - this will collect the input field values of username and text to as a review, thereby triggering an event broadcast on the backend, so all clients will receive in real time.

Finalizing and testing the package

Here, we will test the package we have just built. Inside the main Laravel app composer.json file we need to add a new repository definition like this:

1// [...]
2    
3    "repositories": [
4      {
5        "type": "path",
6        "url": "./packages/*/*/",
7        "options": {
8            "symlink": true
9        }
10      }
11    ]
12    
13    // [...]

This will let Composer include a new repository that contains our package and symlink it to the package code folder. Now to use our package in the Laravel app, add this snippet to the require section in composer.json file:

1// [...]
2    
3    "require": {
4        "Acme/pagereview": "@dev"
5    }
6    
7    // [...]

After that, run this command in the Laravel app directory:

    $ composer update --prefer-source 

This command will update all packages required and also bring in our newly developed local package.

Now, we need to add a layout file using the auth artisan command. Inside our Laravel app directory, run the command below:

    $ php artisan make:auth

In the root directory of our Laravel project open the resources/views/layouts folder and open a file called app.blade.php. After that, add this code below the last div tag:

1<script src="{{ asset('js/app.js') }}"></script>
2    <script src="https://js.pusher.com/4.2/pusher.min.js"></script>
3    @include('pagereview::section')
  • The script tag is used to fetch the Pusher.js library file.
  • @include is a Laravel blade directive that allows you to include a blade view from within another view. In this case, we are including our package section.blade.php view.

Now, create a new pagereview.blade.php file inside the resources/views folder of the Laravel app directory. After creating it, paste the snippet below:

1@extends('layouts.app')
2    @section('content')
3    <div class="container">
4        <div class="d-flex justify-content-center">
5            <div>
6                <h1 class="mb-4">
7                    Laravel Page Review
8                </h1>
9                <p>A simple package to add reviews to a page</p>
10            </div>
11        </div>
12    </div>
13    @endsection

Also, open routes/web.php in the Laravel app and add this snippet:

1Route::get('/test', function () {
2        return view('pagereview');
3    });

Now, open the .env file in our Laravel app directory and update the following properties with keys from your Pusher dashboard:

1PUSHER_APP_ID="PUSHER_APP_ID"
2    PUSHER_APP_KEY="PUSHER_APP_KEY"
3    PUSHER_APP_SECRET="PUSHER_APP_SECRET"
4    PUSHER_APP_CLUSTER="PUSHER_APP_CLUSTER"

We will use SQLite for testing our package. So, still in the same .env file, update the .env configurations below:

1DB_CONNECTION=sqlite
2    DB_DATABASE=/path/to/database.sqlite

Next, create a new SQLite database by creating a file called database.sqlite in the database directory.

Next, run this command to migrate the database:

    $ php artisan migrate

Finally, let’s serve the application using this command:

    $ php artisan serve

To test that our Laravel package is working properly visit the page URL http://localhost:8000/test on two separate browser windows. Then make a review on the same page on each of the browser windows and check that it updates in realtime on the other window.

Conclusion

So far, we learned how to build a simple Laravel package using Laravel and Vue. In this tutorial, we created a reviews package for pages and also made it realtime using Pusher. which can be used in any Laravel application.

The code is available on GitHub.