14 min read

Laravel Vue and FullCalendar integration

Zak

I was working on a project and my task was to make an appointment system for real estate agents.

The project was a CRM for real estate agencies and they were struggling to keep all their appointments in one place and with a single source of truth.

Of course they could have just used Google Calendar or similar, but then managers would lose the ability to have an overview of their agents' appointments.

My idea was to use the existing backend, written in Laravel adding FullCalendar and Vue.

A screenshot of the calendar My customer was Italian, thus the locale in the screenshots.

Let's get started: the backend (Laravel)

I won't go through all the Laravel application scaffolding. You can read about it in the official docs.

Let's assume you already have a Laravel application up and running, with user authentication in place.

Setting up the database: model and migrations

Let's create a new model. I called it Appointment (to avoid confusion with the Laravel native Event).

php artisan make:model Appointment -m -cr

This command will scaffold a new model, a migration and a resourceful controller.

Appointment schema

Let's define how Appointment is structured in our database.

Inside the migration we just created (you can find it in your database/migrations folder):

<?php

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

class CreateAppointmentsTable extends Migration
{
    /**
     * Run the migrations.
     *
     * @return void
     */
    public function up()
    {
        Schema::create('appointments', function (Blueprint $table) {
            $table->bigIncrements('id');
            $table->timestamps();
            $table->text('title');
            $table->text('description')->nullable();
            $table->dateTime('start');
            $table->dateTime('end');
            $table->integer('user_id')->unsigned();
            $table->foreign('user_id')->references('id')->on('users');
        });
    }

    /**
     * Reverse the migrations.
     *
     * @return void
     */
    public function down()
    {
        Schema::dropIfExists('appointments');
    }
}

You can tweak your Appointment schema according to you needs.

Let's review the one I did.

  • A title column for the appointment title. This is the text appearing on the event in the calendar.
  • A description column for the event details (like location or whatever). This field is optional so I put nullable() on it.
  • start and end datetime fields.
  • a foreign index user_id that references our users table.

We can now run this command to migrate our new table structure.

php artisan migrate

Appointment <-> User relationships

Inside app/Http/Appointment.php we need to define the relationship with our User model.

An appointment belongs to one specific user, so we use the belongsTo() relationship.

You can read more about eloquent relationships in Laravel offiical docs.

<?php

namespace App;

use Illuminate\Database\Eloquent\Model;

class Appointment extends Model
{
    protected $guarded = [ 'id' ];

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

On the other end, User can have many Appointments. So inside app/User.php

<?php
/* Add this after your existing code */

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

Create, read, index, delete appointments

Ok, now that we have our database and models in place, let's move to application logic.

Keep in mind that I wanted the application to look as much as possible like an SPA. User just needed to hit the /appointments route and do everything from there, as async as possibile, leveraging axios and Vue.

Routes & endpoints

I avoided the typical Laravel approach (forms, hard page refresh, etc.) and did a small API for calendar CRUD options.

This will get clearer later on in the article where I explain the frontend part.

This is how my routes/web.api looks like (the relevant part):

<?php

/* Other routes here */

Route::get('/appointments', 'AppointmentController@index');
Route::get('/appointments/filter', 'AppointmentController@filter');
Route::post('/appointments/new', 'AppointmentController@store');
Route::patch('/appointments/{appointment}/edit', 'AppointmentController@update');
Route::delete('/appointments/{appointment}', 'AppointmentController@destroy');

Appointments controller & logic handling

Here is my AppointmentController.

<?php

namespace App\Http\Controllers;

use App\Appointment;
use Illuminate\Http\Request;

class AppointmentController extends Controller
{
    /**
     * Display a listing of the resource.
     *
     * @return \Illuminate\Http\Response
     */
    public function index()
    {
        return view('appointments.index');
    }

    /**
     * Store a newly created resource in storage.
     *
     * @param  \Illuminate\Http\Request  $request
     * @return \Illuminate\Http\Response
     */
    public function store(Request $request)
    {
        /* If user is admin asign to specific ID else assign to authUser */
        $assignee =
          ($request->assignee && auth()->user()->isAdmin ?
          $request->assignee :
          $assignee = auth()->user()->id;

        $appointment = new Appointment([
          'start' => $request->start,
          'end' => $request->end,
          'title' => $request->title,
          'description' => $request->description,
          'user_id' => $assignee
        ]);

        $appointment->save();

        return $event;
    }

    /**
     * Display the specified resource.
     *
     * @param  \Illuminate\Http\Request  $request
     * @return \Illuminate\Http\Response
     */
    public function filter(Request $request)
    {
        if (auth()->user()->isAdmin) {
          return Appointment::whereBetween('start', [$request->start, $request->end])
                  ->with('user:id,name,lastname')
                  ->get();
        } else {
          return Appointment::whereBetween('start', [$request->start, $request->end])
                  ->where('user_id', auth()->user()->id)
                  ->get();
        }
    }

    /**
     * Update the specified resource in storage.
     *
     * @param  \Illuminate\Http\Request  $request
     * @param  \App\Appointment  $appointment
     * @return \Illuminate\Http\Response
     */
    public function update(Request $request, Appointment $appointment)
    {
        $user = auth()->user();
        if ($appointment->user_id === $user->id || $user->isAdmin) {
          $appointment->update($request->all());
        } else {
          abort(403);
        }
    }

    /**
     * Remove the specified resource from storage.
     *
     * @param  \App\Appointment  $appointment
     * @return \Illuminate\Http\Response
     */
    public function destroy(Appointment $appointment)
    {
      $user = auth()->user();
      if ($appointment->user_id === $user->id || $user->isAdmin) {
        $appointment->delete();
      } else {
        abort(403);
      }
    }
}

Is User an administrator?

As you can see, in my controller I check if User is an administrator (auth()->user()->isAdmin).

You can achieve this in different ways.

The simplest one would be to add a method inside your User model:

<?php
// Your existing code here

public function isAdmin() {
  return (bool) $this->id === 1;
}

This way only the user with id of 1 has the ability to view and delete every appointment.

Other users can only view, delete and update their personal appointments.

An alternative would be to use a package for roles and permissions management, like Spatie Laravel-Permission package.

A note on filter(Request $request) method inside AppointmentController.

As you will see later on in the article, FullCalendar has its own way of handling fetching events from the backend.

For example if you are in the day view it only fetches appointments of that day's date. If you are in monthly view it fetches all events related to the selected month and so on. Pretty neat, if you ask me.

Frontend: let's display the calendar

Now that we have all the login set up, we are ready to move on the frontend.

As you might have noticed in the routes/web.php, there is only one view responsible for the frontend: resources/views/appointments/index.blade.php.

I will share only the relevant parts of my HTML, yours will depend on your template, theme etc.

@section('content')
<div class="row">
	<div class="col-md-12">
		<div class="card p-3">
            <calendar />
		</div>
	</div>
</div>
@endsection

Ok, pretty straightforward. As you can see it is plain Bootstrap CSS, with a single VueJS component: <calendar />.

If you hit your /appointments route right now, you will see an empty card. That's because we need to create the <calendar /> component.

Let's tackle this one.

Calendar component and FullCalendar

Inside resources/js/components I created a new file, and named it Calendar.vue. Let's leave it empty for now, we'll be back on it later on.

To make your component globally available, add this line to your resources/js/app.js

Vue.component('calendar', require('./components/Calendar.vue').default);

Ok, next step is installing FullCalendar. Luckily there is a ready-made integration with Vue, here are the docs in case you might need them for an in-depth overview.

npm install --save @fullcalendar/vue @fullcalendar/core @fullcalendar/daygrid @fullcalendar/timegrid @fullcalendar/interaction

Let's get back to resources/js/components/Calendar.vue.

Here's what it looks like.

<template>
<div>
    <FullCalendar
        ref="fullCalendar"
        defaultView="timeGridDay"
        :options="calendarOptions"
        :header="{
            left: 'prev,next today',
            center: 'title',
            right: 'dayGridMonth,timeGridWeek,timeGridDay,listWeek'
          }"
        :plugins="calendarPlugins"
        :weekends="calendarWeekends"
        :events="calendarEvents"
        @dateClick="handleDateClick"
        @eventDrop="handleEventDrop"
        @eventClick="handleEventClick"
        @eventResize="eventResize"
        :editable="true"
        navLinks="true"
        timeZone="UTC"
    />

    <add-appointment-modal
      :show="new_event_modal_open"
      :date="new_event_details"
      @close="resetNewEventData"
      @event-created="newEventCreated"
    />

    <show-appointment-modal
      :show="show_event_details_modal"
      :event="current_event"
      @close="show_event_details_modal = false"
      @event-deleted="rerenderCalendar"
      @event-updated="rerenderCalendar"
    />

</div>
</template>

<script>
import FullCalendar from "@fullcalendar/vue"
import dayGridPlugin from "@fullcalendar/daygrid"
import timeGridPlugin from "@fullcalendar/timegrid"
import interactionPlugin from "@fullcalendar/interaction"
import AddAppointmentModal from './AddAppointmentModal'
import ShowAppointmentModal from './ShowAppointmentModal'
import Noty from 'noty'

import "@fullcalendar/core/main.css"
import "@fullcalendar/daygrid/main.css"
import "@fullcalendar/timegrid/main.css"

export default {
    name: 'Calendar',
    components: {
        FullCalendar,
        AddAppointmentModal,
        ShowAppointmentModal
    },
    data: () => ({
        new_event_modal_open: false,
        event_detail_modal_open: false,
        new_event_details: {
          start: null,
          end: null,
        },
        current_event: null,
        show_event_details_modal: false,

        /* Full Calendar Options Start */
        calendarPlugins: [
            dayGridPlugin,
            timeGridPlugin,
            interactionPlugin
        ],
        calendarWeekends: true,
        calendarEvents:
          {
            url: '/appointments/filter'
          },
        locale: itLocale,
        calendarOptions: {
            eventLimit: true,
            views: {
                timeGrid: {
                    eventLimit: 4
                },
                monthGrid: {
                    eventLimit: 4
                },
                dayGrid: {
                    eventLimit: 4,
                }
            },
        },
        /* Full Calendar Options End */
    }),

    methods: {
        handleDateClick(e) {
            this.new_event_modal_open = true
            this.new_event_start = e.dateStr
            let endTime = (new Date(e.dateStr)).toISOString()
            this.new_event_details.start = e.dateStr
            this.new_event_details.end = endTime
        },

        handleEventDrop(e) {
            let updatedEventData = {
              start: e.event.start,
              end: e.event.end
            }
            this.$api.appointments.update(e.event.id, updatedEventData)
              .then( ({data}) => {
                new Noty({
                  text: `Event has been updated.`,
                  timeout: 700,
                  type: 'success'
                }).show()
              })
              .catch( error => {
                e.revert()
                new Noty({
                  text: `Oops, something bad happened while updating your event.`,
                  timeout: 1000,
                  type: 'error'
                }).show()
              })
        },

        handleEventClick(e) {
            this.current_event = e.event
            this.show_event_details_modal = true
        },

        formatDate(date) {
          return moment.utc(date).format('DD/MM/YY HH:mm')
        },

        resetNewEventData() {
          this.new_event_details.start = null
          this.new_event_details.end = null
          this.new_event_details.title = null
          this.new_event_modal_open = false
        },

        newEventCreated() {
          this.rerenderCalendar()
          this.new_event_modal_open = false
          this.resetNewEventData()
          new Noty({
            text: `Appointment has been created.`,
            timeout: 1000,
            type: 'success'
          }).show()
        },

        eventResize(e) {
          let updatedEventData = {
            start: e.event.start,
            end: e.event.end
          }
          this.$api.appointments.update(e.event.id, updatedEventData)
            .then( ({data}) => {
              new Noty({
                text: `Appointment duration updated.`,
                timeout: 1000,
                type: 'success'
              }).show()
            })
            .catch( error => {
              e.revert()
              new Noty({
                text: `Oooops, couldn't update appointment duration. Sorry.`,
                timeout: 1000,
                type: 'error'
              }).show()
            })
        },

        rerenderCalendar() {
          this.$refs.fullCalendar.getApi().refetchEvents()
        }
    },
};
</script>

<style>
    .fc-content {
        color: white;
    }
</style>

If you fire up Chrome and go to /appointments, this is what you will see.

Empty calendar view

But right now our calendar doesn't really do much. In fact it only displays an empty calendar, nothing more.

Fetching events on calendar render

As you see, there is prop called events in the FullCalendar component inside our Calendar.vue.

We need to populate the calendar with existing events. Luckily FullCalendar has a built-in logic for that.

We just need to define our endpoint for listing our appointments inside the data() object (I already included that in the snippet I shared with you). Remember the filter() method inside the AppointmentController and the /appointments/filter route? That's what we need them for.

This means that right after we implement event creation, they will immediately be displayed on our calendar. Cool! Let's do it!

Appointment creation

The usual UX for creating a new event. To create an event inside a calendar, you are expected to click on a date or on a time slot, you are redirected to a new page where you input the event title and description.

Finally you submit the form and you get redirected back to your main calendar view.

I think this is BAD UX. It involves a page hard refresh.

Let's use modals for a better UX

To solve this issue, I created a new component. I called it AddAppointmentModal (resources/js/components/AddAppointmentModal.vue).

Here's what it looks like (we are using traditional Bootstrap CSS here):

Appointment creation modal

Ok, let's see the code:

<template>
<div v-if="show">
    <div class="modal fade show display" v-cloak tabindex="-1" role="dialog" aria-labelledby="AddAppointmentModal" aria-hidden="true">
        <div class="modal-dialog modal-dialog-centered" role="document">
            <div class="modal-content">
                <div class="modal-header">
                    <h5 class="modal-title">New Appointment</h5>
                    <button type="button" class="close" @click="closeModal" aria-label="Close">
                        <span aria-hidden="true">×</span>
                    </button>
                </div>

                <div class="p-2">
                    <ul class="list-group list-group-flush">
                        <li class="list-group-item">
                            <div class="input-group input-group-seamless">
                                <div class="input-group-prepend">
                                    <div class="input-group-text">
                                        <i class="material-icons">edit</i>
                                    </div>
                                </div>
                                <input type="text" class="form-control w-100 modal-title" v-model="event.title" placeholder="Add title ..." ref="eventTitle" autofocus>
                            </div>
                        </li>
                        <li class="list-group-item">
                            <i class="material-icons">event</i>
                            {{ formatDate(date.start) }}
                        </li>
                        <li v-if="users.length > 0" class="list-group-item">
                            <div class="input-group input-group-seamless mb-3" v-if="users.length > 0">
                                <div class="input-group-prepend">
                                    <label class="input-group-text" for="userSelect">
                                        <i class="material-icons">assignment_ind</i>
                                    </label>
                                </div>
                                <select class="custom-select" id="userSelect" v-model="event.assignee">
                                    <option disabled selected value="nobody">
                                        Assign to:
                                    </option>
                                    <option v-for="user in users" :key="user.id" :value="user.id">
                                        {{ user.full_name }}
                                    </option>
                                </select>
                            </div>
                        </li>
                        <li class="list-group-item">
                            <div class="form-group">
                                <textarea class="form-control" id="appointmentNote" rows="3" v-model="event.note" placeholder="Description ...">
								</textarea>
                            </div>
                        </li>
                    </ul>
                </div>

                <div class="modal-footer">
                    <button type="button" class="btn btn-secondary" @click="closeModal" data-dismiss="modal">
                        Annulla
                    </button>
                    <button type="button" class="btn btn-primary" @click="saveEvent" :disabled="!validEventData">
                        Salva
                    </button>
                </div>

            </div>
        </div>
    </div>
    <div v-cloak class="modal-backdrop fade show custom-modal-backdrop"></div>
</div></template>

<script>
export default{
	props: ['show', 'date'],
	    data: () => ({
	        event: {
	            title: null,
	            assignee: 'nobody',
	            note: null
	        },
	        users: []
	    }),

	    methods: {
	        closeModal() {
	            this.event.title = null
	            this.event.assignee = 'nobody'
	            this.event.note = null
	            this.$emit('close')
	        },

	        formatDate(date, format = 'DD/MM/YY HH:mm') {
	            return moment.utc(date).format(format)
	        },

	        transformEventDates(start, end) {
	            // if start is same as end add 1hr
	            let startTime = new Date(start)
	            let endTime = new Date(end)

	            if (startTime.getTime() === endTime.getTime()) {
	                let endTime = (new Date(end))
	                endTime.setHours(endTime.getHours() + 1)
	                return {
	                    start,
	                    end: endTime.toISOString()
	                }
	            }
	            return {
	                start,
	                end
	            }
	        },

	        saveEvent() {
	            let eventData = this.transformEventDates(this.date.start, this.date.end)
	            let newEventData = {
	                start: eventData.start,
	                end: eventData.end,
	                title: this.event.title,
	                assignee: this.event.assignee,
	                note: this.event.note
	            }

	            this.$api.appointments.create(newEventData)
	                .then(({
	                    data
	                }) => {
	                    this.closeModal()
	                    this.$emit('event-created')
	                })
	                .catch(error => {
	                    this.$emit('error')
	                })
	        }

	    },

	    computed: {
	        validEventData() {
	            return !!(this.event.title && this.event.assignee != 'nobody')
	        }
	    },

	    mounted() {
	        // I absctracted my API calls, this would be the same as:
	        // axios.get('/users').then( .... ) ...
	        this.$api.users.index()
	            .then(({
	                data
	            }) => {
	                this.users = data
	            })
	            .catch(error => {
	                this.users = []
	                this.event.assignee = null
	            })
	    }
	}
</script>

As you can see in the Calendar.vue code, there is an event handler called @dateClick="handleDateClick".

This takes care of displaying the modal for event creation.

🚨WARNING: I used some api abstractions to map my backend endpoint to the frontend. 🚨

Lines where you see this.$api.something can be replaced with axios.get(YOUR_ENDPOINT) or axios.post(YOUR_ENDPOINT).

🚨WARNING 2: In my code I gave the ability to admins to assign an appointment to other users, if you don't need this part, edit accordingly in AddAppointmentModal component and inside AppointmentController. 🚨

Editing and deleting appointments

Now that we can add new appointments to our calendar, we need a way to delete them or to update them.

In the previous code I shared with you, there's a component called ShowAppointmentModal.

When you click on an existing event, a modal with event details pops up.

From within that modal you can also edit or delete the appointment.

Appointment drag & drop, resizing

Thanks to FullCalendar's flexibility, you can drag and drop any appointment and it will be updated in the backend reflecting changes immediately on the frontend. If an error occurs, the appointment gets restored to its original time and date slot.

Same thing for appointment resizing, try it!

This is handled inside the Calendar.vue component:

<template>
<FullCalendar
  // ....
  @eventDrop="handleEventDrop"
  @eventResize="eventResize"
  :editable="true"
  // ...
/>
</template>
<script>
// ....
methods: {
    handleEventDrop(e) {
        let updatedEventData = {
            start: e.event.start,
            end: e.event.end
        }
        this.$api.appointments.update(e.event.id, updatedEventData)
            .then(({
                data
            }) => {
                new Noty({
                    text: `Event has been updated.`,
                    timeout: 700,
                    type: 'success'
                }).show()
            })
            .catch(error => {
                e.revert()
                new Noty({
                    text: `Oops, something bad happened while updating your event.`,
                    timeout: 1000,
                    type: 'error'
                }).show()
            })
    },
    eventResize(e) {
      let updatedEventData = {
        start: e.event.start,
        end: e.event.end
      }
      this.$api.appointments.update(e.event.id, updatedEventData)
        .then( ({data}) => {
          new Noty({
            text: `Appointment duration updated.`,
            timeout: 1000,
            type: 'success'
          }).show()
        })
        .catch( error => {
          e.revert()
          new Noty({
            text: `Oooops, couldn't update appointment duration. Sorry.`,
            timeout: 1000,
            type: 'error'
          }).show()
        })
    },

    rerenderCalendar() {
      this.$refs.fullCalendar.getApi().refetchEvents()
    }
}
</script>

Hope you enjoyed this article.

Code is self explaining but might you have any question feel free to contact me on twitter @thugic or drop me an email hello@zako.dev