Deep Diving Laravel Nova

Nick Basile • August 23, 2018

Heads up! This post is over a year old, so it might be out of date.

In my previous post, we saw how to get started with Nova and use it to create a simple blog. While that's a nice "Hello World" example, I'd like to explore more of Nova's features and get a better feel for using it.

So, for this deep dive, let's build a very light CRM that lets us capture and manage leads. In the process, we'll take a look at how to use Nova's metrics; customize the search bar, and figure out filters and lenses.

Let's dive right in!

Getting Started

If you're brand new to Nova, I recommend checking out my previous post where I explained in detail how to install and set up Nova on your machine. For this walkthrough, I'm going to assume that we have Nova installed in a fresh Laravel project. So, you should be able to log in and see this screen:

Visitor Submissions

Before we dive into the Nova side of things, we'll need to scaffold out our models like we always do with Laravel. For our CRM, let's work with two models to start.

We'll have a Lead model which we'll use to keep track of all the visitors interacting with us. Also, let's have a Note model, so our admin users can leave notes for themselves about the Leads. So, for our relationship, our Leads will have many Notes, and our Users will have many Notes as well.

Let's start building these models by running, php artisan make:model Lead -a and php artisan make:model Note -a from our terminal.

Migrations

With these files added, we can jump into the migrations and add our columns. Our Lead migration will look like this:

public function up()
{
    Schema::create('leads', function (Blueprint $table) {
        $table->increments('id');
        $table->text('type');
        $table->text('status');
        $table->text('first_name');
        $table->text('last_name');
        $table->text('full_name');
        $table->text('email');
        $table->timestamps();
    });
}

Then our Note migration will look like this:

public function up()
{
    Schema::create('notes', function (Blueprint $table) {
        $table->increments('id');
        $table->integer('user_id');
        $table->integer('lead_id');
        $table->text('priority');
        $table->text('title');
        $table->text('body');
        $table->timestamps();
    });
}

Now we can run php artisan migrate to add those tables to our database. With that taken care of, let's define our relationships on the models.

Models

If we recall, we said that our Notes model would have many Leads and Users, so let's set up those relationships first. In Note.php, we can add the following:

class Note extends Model
{
    public function user()
    {
        return $this->belongsTo('App\User');
    }

    public function lead()
    {
        return $this->belongsTo('App\Lead');
    }
}

Then, inside of User.php and Lead.php, we can add our Notes relationship like this:

//User.php
class User extends Authenticatable
{
    use Notifiable;

    /**
     * The attributes that are mass assignable.
     *
     * @var array
     */
    protected $fillable = [
        'name', 'email', 'password',
    ];

    /**
     * The attributes that should be hidden for arrays.
     *
     * @var array
     */
    protected $hidden = [
        'password', 'remember_token',
    ];

    public function notes()
    {
        return $this->hasMany('App\Note');
    }
}

//Lead.php
class Lead extends Model
{
    public function notes()
    {
        return $this->hasMany('App\Note');
    }
}

Next, in our Lead model, we'll need to define some $fillable attributes so our leads can provide their information. Let's also override the boot() method so we can generate the Lead's full_name from their first_name and last_name attributes. So, inside of Lead.php, we'll have the following:

class Lead extends Model
{
    protected $fillable = [
        'type',
        'status',
        'first_name',
        'last_name',
        'full_name,
        'email',
    ];

    protected static function boot()
    {
        parent::boot();

        static::saving(function ($lead) {
            $lead->full_name = $lead->first_name . ' ' . $lead->last_name;
        });
    }

    public function notes()
    {
        return $this->hasMany('App\Note');
    }
}

Finally, let's provide some constants and helper methods in our Lead and Note models so we can easily access their type, status, and priority values. These helpers aren't strictly required, but I find it helpful to have these values defined up front, so I don't change them on a whim. So our models will look like this:

//Note.php
class Note extends Model
{
    const LOW_PRIORITY = 'Low';
    const MEDIUM_PRIORITY = 'Medium';
    const HIGH_PRIORITY = 'High';

    public function user()
    {
        return $this->belongsTo('App\User');
    }

    public function lead()
    {
        return $this->belongsTo('App\Lead');
    }

    public static function getPriorities()
    {
        return [
            self::LOW_PRIORITY => self::LOW_PRIORITY,
            self::MEDIUM_PRIORITY => self::MEDIUM_PRIORITY,
            self::HIGH_PRIORITY => self::HIGH_PRIORITY,
        ];
    }
}

//Lead.php
class Lead extends Model
{
    const ORGANIC_TYPE = 'Organic';
    const USER_SUBMITTED_TYPE = 'User Submitted';

    const PROSPECT_STATUS = 'Prospect';
    const LEAD_STATUS = 'Lead';
    const CUSTOMER_STATUS = 'Customer';

    protected $fillable = [
        'type',
        'status',
        'first_name',
        'last_name',
        'full_name',
        'email',
    ];

    public function notes()
    {
        return $this->hasMany('App\Note');
    }

    public static function getTypes()
    {
        return [
            self::ORGANIC_TYPE => self::ORGANIC_TYPE,
            self::USER_SUBMITTED_TYPE => self::USER_SUBMITTED_TYPE,
        ];
    }

    public static function getStatuses()
    {
        return [
            self::PROSPECT_STATUS => self::PROSPECT_STATUS,
            self::LEAD_STATUS => self::LEAD_STATUS,
            self::CUSTOMER_STATUS => self::CUSTOMER_STATUS,
        ];
    }
}

As I said, this approach helps me, and I hope it can help you out. If you don't like it, feel free to ignore this part. That said, we'll use these methods to help us keep our models in-sync with their Nova resources, so it won't hurt to try it out.

Just like that, we've finished our models. Now, let's create a quick form on the non-Nova side of our app so visitors can enter their details and become leads.

Visitor Form

To style our form, we'll use Tailwind CSS' CDN to get up and running quickly. We can keep using welcome.blade.php, and we'll clear out the default HTML and drop in the CDN.

<!doctype html>
<html lang="{{ app()->getLocale() }}">
<head>
    <meta charset="utf-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <title>Nova CRM Demo</title>
    <!-- Fonts -->
    <link href="https://fonts.googleapis.com/css?family=Nunito:200,600" rel="stylesheet" type="text/css">
    <!-- Styles -->
    <link href="https://cdn.jsdelivr.net/npm/tailwindcss/dist/tailwind.min.css" rel="stylesheet">
</head>
<body>

</body>
</html>

Now let's grab one of the example forms from the Tailwind docs (thank you Adam et al!) and quickly spin up our form.

<body>
    <div class="flex flex-col items-center justify-center md:h-screen md:bg-blue-lightest">
        <form class="w-full max-w-md bg-white rounded p-10 md:shadow" action="/form-submit" method="POST">
            <h1 class="text-grey-darkest mb-8 text-center">Sign Up For Our Nova App</h1>
            {{ csrf_field() }}
            <div class="flex flex-wrap -mx-3 mb-6">
                <div class="w-full md:w-1/2 px-3 mb-6 md:mb-0">
                    <label class="block uppercase tracking-wide text-grey-darker text-xs font-bold mb-2" for="first-name">
                        First Name
                    </label>
                    <input class="appearance-none block w-full bg-grey-lighter text-grey-darker border rounded py-3 px-4 leading-tight focus:outline-none focus:bg-white {{ $errors->has('first_name') ? 'border-red mb-3' : 'border-grey-lighter' }}" id="first_name" name="first_name" type="text" placeholder="Jane">
                    @if($errors->has('first_name'))
                        <p class="text-red text-xs italic">{{ $errors->first('first_name') }}</p>
                    @endif
                </div>
                <div class="w-full md:w-1/2 px-3">
                    <label class="block uppercase tracking-wide text-grey-darker text-xs font-bold mb-2" for="last-name">
                        Last Name
                    </label>
                    <input class="appearance-none block w-full bg-grey-lighter text-grey-darker border rounded py-3 px-4 leading-tight focus:outline-none focus:bg-white focus:border-grey {{ $errors->has('last_name') ? 'border-red mb-3' : 'border-grey-lighter' }}" id="last_name" name="last_name" type="text" placeholder="Doe">
                    @if($errors->has('email'))
                        <p class="text-red text-xs italic">{{ $errors->first('last_name') }}</p>
                    @endif
                </div>
            </div>
            <div class="flex flex-wrap -mx-3 mb-6">
                <div class="w-full px-3">
                    <label class="block uppercase tracking-wide text-grey-darker text-xs font-bold mb-2" for="email">
                        Email
                    </label>
                    <input class="appearance-none block w-full bg-grey-lighter text-grey-darker border rounded py-3 px-4 leading-tight focus:outline-none focus:bg-white focus:border-grey {{ $errors->has('email') ? 'border-red mb-3' : 'border-grey-lighter' }}" id="email" name="email" type="email" placeholder="jane@example.com">
                    @if($errors->has('email'))
                        <p class="text-red text-xs italic">{{ $errors->first('email') }}</p>
                    @endif
                </div>
            </div>
            <button class="block bg-blue px-6 py-4 text-white rounded mx-auto hover:bg-blue-dark" type="submit">Submit</button>
        </form>
        @if (session('form-success'))
            <div class="bg-green mt-8 p-6 rounded shadow">
                <p class="text-white">{{ session('form-success') }}</p>
            </div>
        @endif
    </div>
</body>

Not too shabby! Now, we can hop into our web.php file to define the form-submit route that our form should POST to.

Route::get('/', function () {
    return view('welcome');
});

Route::post('/form-submit', 'LeadController@store');

Inside of our LeadController's store() method, we'll do a bit of validation, add some default data using the constants from earlier, and then save a new lead.

public function store(Request $request)
{
    $validatedData = $request->validate([
        'first_name' => 'required|string',
        'last_name' => 'required|string',
        'email' => 'required|email|unique:leads,email',
    ]);

    $lead = new Lead;

    $lead->first_name = $validatedData['first_name'];
    $lead->last_name = $validatedData['last_name'];
    $lead->email = $validatedData['email'];
    $lead->type = Lead::ORGANIC_TYPE;
    $lead->status = Lead::PROSPECT_STATUS;

    $lead->save();

    return redirect()->back()
        ->with('form-success', 'Thank you for your submission!');
}

Now our visitors can submit their details. With the Laravel side of things taken care of, we're ready to move onto Nova!

Nova CRM

To get our Nova admin panel working, we can start by adding some resources for the Lead and Note models. To do that'll, we'll run php artisan nova:resource Lead and php artisan nova:resource:Note from our terminal. With the resources generated, let's go through the Lead resource from top to bottom and configure it for our app.

Resources

First up, we have the $model definition which we can leave alone. Next, is the $title - this is what Nova will use to display our Lead around our admin. Let's update that to our Lead's full_name so it's a bit easier to understand at a glance.

public static $title = 'full_name';

Next, in the $search array, we can define the fields we want to be able to search on. Let's add a few of our Lead's attributes so we can find them quickly.

public static $search = [
    'id',
    'full_name',
    'email',
];

With that taken care of, we're ready to define our fields.

public function fields(Request $request)
{
    return [
        ID::make()->sortable(),
        Text::make('Full Name')
            ->exceptOnForms()
            ->sortable(),
        Select::make('Status')
            ->options(\App\Lead::getStatuses())
            ->sortable()
            ->rules('required', 'string'),
        Select::make('Type')
            ->options(\App\Lead::getTypes())
            ->sortable()
            ->rules('required', 'string'),
        Text::make('First Name')
            ->onlyOnForms()
            ->rules('required', 'string'),
        Text::make('Last Name')
            ->onlyOnForms()
            ->rules('required', 'string'),
        Text::make('Email')
            ->sortable()
            ->rules('required', 'email')
            ->creationRules('unique:leads,email')
            ->updateRules('unique:leads,email,{{resourceId}}'),
        HasMany::make('Notes'),
    ];
}

As you can see, we've got a lot more going on here than we did in the previous post. For example, we use the exceptOnForms() and onlyOnForms() display options with our name values. On our email field, we're doing some special validation to ensure that we maintain the uniqueness of the emails.

Also, notice that our model methods are making an appearance now! We can use them to populate the select fields, so our values keep in sync. Maybe a bit overkill, but I like knowing that I can change that in one place and everything will stay updated. Finally, we can see that all the fields that appear in the index are sortable.

Taking a look at our Nova admin, we can see that the index has built itself and our forms are now usable!

Now visitors can submit their data, and our team can upload their leads! It would be nice if we could keep track of any edits or changes that our admin users make on the Leads and Notes for management purposes. Fortunately, Nova makes this super easy!

We just need to add the Actionable trait to our models, and we'll get a list of actions performed on the model.

//In app\Lead.php and app\Note.php
use Actionable;

How cool is that? With our Lead resource set up, let's work on our Note resource. Now that we're getting the hang of things let's quickly run through all of the changes at once.

class Note extends Resource
{
    public static $model = 'App\Note';

    public static $title = 'title';

    public static $search = [
        'id',
        'title',
    ];

    public function fields(Request $request)
    {
        return [
            ID::make()->sortable(),
            Text::make('Title')
                ->sortable()
                ->rules('required', 'string'),
            Select::make('Priority')
                ->options(\App\Note::getPriorities())
                ->sortable()
                ->rules('required', 'string'),
            Markdown::make('Body')
                ->rules('required', 'string'),
            BelongsTo::make('Lead')
                ->sortable()
                ->rules('required'),
            BelongsTo::make('User')
                    ->sortable()
                ->rules('required'),
        ];
    }

    ...
}

We haven't done too much here, apart from the fields() definition, so I'll let you unpack all of the changes. Finally, we'll also need to add a relationship definition to our User resource so we can see their Notes.

public function fields(Request $request)
{
    return [
        ID::make()->sortable(),

        Gravatar::make(),

        Text::make('Name')
            ->sortable()
            ->rules('required', 'max:255'),

        Text::make('Email')
            ->sortable()
            ->rules('required', 'email', 'max:255')
            ->creationRules('unique:users,email')
            ->updateRules('unique:users,email,{{resourceId}}'),

        Password::make('Password')
            ->onlyOnForms()
            ->creationRules('required', 'string', 'min:6')
            ->updateRules('nullable', 'string', 'min:6'),

        HasMany::make('Notes')
    ];
}

Now we can see the Notes on their index page, their Lead's page, and their User's page!

Just like that, we have the CRUD functionality of our CRM all wrapped up. However, we're missing a few key features that would make our CRM data super actionable. Let's work on adding those features now!

Metrics

What's a CRM without any metrics? Out-of-the-box, Nova makes it super easy for us to add metrics to our admin. So, what do we want to see? It would be great to see the Lead growth over time; how many Leads we've gained in a given period, and the breakdown of each type of Lead. Let's whip those right up!

Starting with our Lead growth metric, we can use the Nova cli tools to quickly scaffold a trend chart for us by running: php artisan nova:trend LeadsPerDay.

This command creates a Metrics directory inside of app\Nova. In here, we can see the config for our new trend chart. All we need to do is replace the default Model value with Lead inside the calculate() method.

public function calculate(Request $request)
{
    return $this->countByDays($request, Lead::class);
}

Now to display our new trend chart, we'll pop back into our Lead resource and register it in the cards() method. Since we'd like it to take up a third of the screen, we'll also chain on a width('1/3') method.

public function cards(Request $request)
{
    return [
        (new Metrics\LeadsPerDay)->width('1/3'),
    ];
}

If we take a look at our Leads index page, we can see that the chart is ready to go!

I don't know about you, but I've spent a significant portion of my life developing custom charts. Nova has made that grueling process a thing of the past. Now, let's crank out the other two.

We'll just run php artisan nova:value NewLeads and php artisan nova:partition LeadsPerStatus. Then, we can hop into those generated files to update the default model with Lead. Inside of LeadsPerStatus, we'll also use status as the groupByColumn. Then, we'll register the new metrics like this:

public function cards(Request $request)
{
    return [
        (new Metrics\LeadsPerDay)->width('1/3'),
        (new Metrics\NewLeads)->width('1/3'),
        (new Metrics\LeadsPerStatus)->width('1/3'),
    ];
}

And just like that, we're ready to show off what we "built" at the next stakeholder meeting!

I just can't get over how easy that process has become. It truly is a paradigm shift because now we can focus on features that our users need instead of getting bogged down in the minutiae of bringing data to life. Don't get me wrong; data wrangling can be fun every now and again. However, building chart after chart can be a little draining. /end-rant

Before we wrap up our CRM, we could use some additional filters and lenses to get a better understanding of our lead data.

Filters

For example, it would be great if we could filter this list to only look leads according to their type or status. Once again, Nova has an easy way for us to make this happen with filters.

We can go ahead and use the Nova cli again to generate our filters by running php artisan nova:filter LeadType and php artisan nova:filter LeadStatus. We now have a Filters directory inside of app\Nova where we can config our filters.

To do that, we'll add a regular Eloquent query inside of the filter's apply() method and then list out the options using our convenient model methods.

//LeadStatus.php
class LeadStatus extends Filter
{
    public function apply(Request $request, $query, $value)
    {
        return $query->where('status', $value);
    }

    public function options(Request $request)
    {
        return Lead::getStatuses();
    }
}

//LeadType.php
class LeadType extends Filter
{
    /**
     * Apply the filter to the given query.
     *
     * @param  \Illuminate\Http\Request  $request
     * @param  \Illuminate\Database\Eloquent\Builder  $query
     * @param  mixed  $value
     * @return \Illuminate\Database\Eloquent\Builder
     */
    public function apply(Request $request, $query, $value)
    {
        return $query->where('type', $value);
    }

    /**
     * Get the filter's available options.
     *
     * @param  \Illuminate\Http\Request  $request
     * @return array
     */
    public function options(Request $request)
    {
        return Lead::getTypes();
    }
}

Now we can register these filters in our Lead resource, and they'll appear on our index page.

public function filters(Request $request)
{
    return [
        new Filters\LeadStatus,
        new Filters\LeadType,
    ];
}

There you have it! The crazy thing is that we can even use both filters at the same time to narrow down our dataset even further. I was kind of hoping that our metrics would have updated as well, but hopefully, we'll see that in a later release!

Let's add one more feature to our CRM to bring home the bacon.

Lenses

In Nova, a lens is similar to a filter except we have full control over how to query and display the data. Having this control is extremely useful - especially if we need to reference a model's relationships.

In our case, we've noticed that Leads with a high number of Notes tend to cost us money because they're asking for more customer support. So, it would be great to have a lens so we can have some visibility into this problem.

Well start this as usual with the Nova cli generator like so: php artisan nova:lens TimeIntensiveLeads. As you may have guessed, this generates a Lenses directory inside of app/Nova where we can see our TimeInstensiveLeads lens.

Lenses are certainly a step up from filters regarding the work we need to do. We'll be in charge of writing our query to get the data we need. So, let's dive in.

First up, we have the query method where we'll be defining the query that selects our data.

Let me preface what you're about to see by saying that I'm not the world's best SQL developer, so my apologies if there is a better way to do this. If you do have a better way, feel free to ping me on Twitter, and I'll get this updated.

Back to our query, it could look something like this:

 public static function query(LensRequest $request, $query)
{
    return $request->withOrdering($request->withFilters(
        $query->select([
            'leads.id',
            'leads.full_name',
            'leads.email',
            'leads.status',
            'leads.type',
            DB::raw('count(notes.id) as Count')
        ])
        ->join('notes', 'leads.id', '=', 'notes.lead_id')
        ->orderBy('Count', 'desc')
        ->groupBy('leads.id', 'leads.full_name')
    ));
}

Now in the fields() method, we can define how we'll be displaying this data.

public function fields(Request $request)
{
    return [
        ID::make('ID', 'id')->sortable(),
        Text::make('Full Name')->sortable(),
        Select::make('Status')->sortable(),
        Text::make('Type')->sortable(),
        Text::make('Email')->sortable(),
        Number::make('Notes Count', 'Count'),
    ];
}

Next, we'll register our custom filters so we can manipulate the results like we're used to doing.

public function filters(Request $request)
{
    return [
        new LeadStatus,
        new LeadType,
    ];
}

Finally, we'll register this in our Lead resource and check out the results!

public function lenses(Request $request)
{
    return [
        new Lenses\TimeIntensiveLeads,
    ];
}

Just like that, we've got ourselves a custom view that provides an actionable look into our data.

The Wrap-Up

Well, there you have it folks! We've used Nova to build a quick CRM for us to use. More importantly, we took a more in-depth look at how we can use Nova's features to fit our way of developing and use-cases. As you followed along, I hope you had a few eureka moments as you made connections between your problems and the solutions Nova presents.

I still can't wait to see what we'll all be able to build with it! As always, feel free to ask me any questions on Twitter. And until next time, happy coding!

To keep learning about Laravel Nova, check out my next post where I deep dive more features by expanding our CRM.

Nick Basile

I craft experiences that help people reach their full potential.

Interested in getting my latest content? Join my mailing list.