Web

Event Calendar in Laravel with FullCalendar v6: A Complete Example

Web · · Published 2026-10-24

A paper wall calendar showing September and October 2007, with one day circled in red
A wall calendar with one date marked in red.

A working event calendar in Laravel needs three pieces: an events table, a JSON endpoint that returns the events overlapping the date range FullCalendar asks for, and a small amount of JavaScript that sends create, move and delete requests back to Laravel with the CSRF token. The example below does all of that with Laravel 10 or 11 and FullCalendar v6.

What you are building

The calendar shows month, week and day views. Selecting a range of days or time slots prompts for a title and stores a new event. Dragging or resizing an event updates it, and clicking an event deletes it after a confirmation. Every change goes through a validated controller action, and the browser never has to load more than the events visible on screen.

Action in the calendarHTTP requestController method
Change view or navigateGET /events?start=…&end=…index
Select a date rangePOST /eventsstore
Drag or resize an eventPATCH /events/{id}update
Click an event, confirmDELETE /events/{id}destroy

Migration and model

Create the model and migration in one step with php artisan make:model Event -m, then define the table. The end column is nullable because FullCalendar allows events without an end, and all_day distinguishes date-only events from timed ones.

<?php

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

return new class extends Migration
{
    public function up(): void
    {
        Schema::create('events', function (Blueprint $table) {
            $table->id();
            $table->string('title');
            $table->dateTime('start');
            $table->dateTime('end')->nullable();
            $table->boolean('all_day')->default(false);
            $table->timestamps();

            $table->index(['start', 'end']);
        });
    }

    public function down(): void
    {
        Schema::dropIfExists('events');
    }
};

Run php artisan migrate. In the model, cast the date columns to Carbon instances and add a helper that produces the shape FullCalendar expects: id, title, start, end and allDay. All-day events are serialized as plain dates; timed events as ISO 8601 strings with an offset, which FullCalendar converts to the browser's time zone.

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;

class Event extends Model
{
    protected $fillable = ['title', 'start', 'end', 'all_day'];

    protected $casts = [
        'start'   => 'datetime',
        'end'     => 'datetime',
        'all_day' => 'boolean',
    ];

    public function toCalendarArray(): array
    {
        $format = $this->all_day ? 'Y-m-d' : DATE_ATOM;

        return [
            'id'     => $this->id,
            'title'  => $this->title,
            'start'  => $this->start->format($format),
            'end'    => $this->end?->format($format),
            'allDay' => $this->all_day,
        ];
    }
}

Note that Event is not a reserved name in Laravel, but it can be confused with the framework's event system (Illuminate\Support\Facades\Event). If that bothers you, call the model CalendarEvent and adjust the table name.

Routes

Add the routes to routes/web.php. Using the web middleware group matters here: it starts the session and enables CSRF verification, which the JavaScript will satisfy with a header. In a real application, wrap the group in ->middleware('auth').

<?php

use App\Http\Controllers\EventController;
use Illuminate\Support\Facades\Route;

Route::get('/calendar', [EventController::class, 'calendar'])->name('calendar');

Route::prefix('events')->name('events.')->group(function () {
    Route::get('/', [EventController::class, 'index'])->name('index');
    Route::post('/', [EventController::class, 'store'])->name('store');
    Route::patch('/{event}', [EventController::class, 'update'])->name('update');
    Route::delete('/{event}', [EventController::class, 'destroy'])->name('destroy');
});

The controller and the JSON feed

Generate it with php artisan make:controller EventController. When FullCalendar is given a URL as its events source, it requests that URL with start and end query parameters covering the visible range (see the events JSON feed documentation). The index method must return every event that overlaps that window, not only events that start inside it, otherwise a multi-day event that began last month disappears from this month's view.

<?php

namespace App\Http\Controllers;

use App\Models\Event;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Carbon;

class EventController extends Controller
{
    public function calendar()
    {
        return view('calendar');
    }

    public function index(Request $request): JsonResponse
    {
        $request->validate([
            'start' => ['required', 'date'],
            'end'   => ['required', 'date', 'after:start'],
        ]);

        $start = Carbon::parse($request->query('start'))->utc();
        $end   = Carbon::parse($request->query('end'))->utc();

        // Overlap test: the event starts before the window ends
        // and ends (or, with no end, starts) after the window starts.
        $events = Event::query()
            ->where('start', '<', $end)
            ->where(function ($q) use ($start) {
                $q->where('end', '>', $start)
                  ->orWhere(fn ($q) => $q->whereNull('end')->where('start', '>=', $start));
            })
            ->orderBy('start')
            ->get()
            ->map->toCalendarArray();

        return response()->json($events);
    }

    public function store(Request $request): JsonResponse
    {
        $event = Event::create($this->validated($request));

        return response()->json($event->toCalendarArray(), 201);
    }

    public function update(Request $request, Event $event): JsonResponse
    {
        $event->update($this->validated($request, partial: true));

        return response()->json($event->toCalendarArray());
    }

    public function destroy(Event $event): JsonResponse
    {
        $event->delete();

        return response()->json(null, 204);
    }

    private function validated(Request $request, bool $partial = false): array
    {
        $required = $partial ? 'sometimes' : 'required';

        $data = $request->validate([
            'title'   => [$required, 'string', 'max:255'],
            'start'   => [$required, 'date'],
            'end'     => ['nullable', 'date', 'after_or_equal:start'],
            'all_day' => ['sometimes', 'boolean'],
        ]);

        foreach (['start', 'end'] as $key) {
            if (! empty($data[$key])) {
                $data[$key] = Carbon::parse($data[$key])->utc();
            }
        }

        return $data;
    }
}

A few details are worth calling out:

Installing FullCalendar with npm and Vite

Laravel 10 and 11 ship with Vite. Install the FullCalendar packages you need:

npm install @fullcalendar/core @fullcalendar/daygrid @fullcalendar/timegrid @fullcalendar/interaction

The interaction plugin is what enables date selection, dragging and resizing; without it, selectable and editable do nothing. Create resources/js/calendar.js:

import { Calendar } from '@fullcalendar/core';
import dayGridPlugin from '@fullcalendar/daygrid';
import timeGridPlugin from '@fullcalendar/timegrid';
import interactionPlugin from '@fullcalendar/interaction';

const csrf = document.querySelector('meta[name="csrf-token"]').content;

async function api(url, method, body) {
  const response = await fetch(url, {
    method,
    headers: {
      'Content-Type': 'application/json',
      'Accept': 'application/json',
      'X-CSRF-TOKEN': csrf,
    },
    body: body ? JSON.stringify(body) : undefined,
  });
  if (!response.ok) {
    const error = await response.json().catch(() => ({}));
    throw new Error(error.message || `HTTP ${response.status}`);
  }
  return response.status === 204 ? null : response.json();
}

function payload(event) {
  return {
    start: event.startStr,
    end: event.endStr || null,
    all_day: event.allDay,
  };
}

document.addEventListener('DOMContentLoaded', () => {
  const el = document.getElementById('calendar');

  const calendar = new Calendar(el, {
    plugins: [dayGridPlugin, timeGridPlugin, interactionPlugin],
    initialView: 'dayGridMonth',
    headerToolbar: {
      left: 'prev,next today',
      center: 'title',
      right: 'dayGridMonth,timeGridWeek,timeGridDay',
    },
    events: '/events',
    selectable: true,
    editable: true,

    select: async (info) => {
      calendar.unselect();
      const title = window.prompt('Event title');
      if (!title) return;
      try {
        const created = await api('/events', 'POST', {
          title,
          start: info.startStr,
          end: info.endStr,
          all_day: info.allDay,
        });
        calendar.addEvent(created);
      } catch (e) {
        alert(`Could not create event: ${e.message}`);
      }
    },

    eventDrop: async (info) => {
      try {
        await api(`/events/${info.event.id}`, 'PATCH', payload(info.event));
      } catch (e) {
        info.revert();
        alert(`Move failed: ${e.message}`);
      }
    },

    eventResize: async (info) => {
      try {
        await api(`/events/${info.event.id}`, 'PATCH', payload(info.event));
      } catch (e) {
        info.revert();
      }
    },

    eventClick: async (info) => {
      if (!window.confirm(`Delete "${info.event.title}"?`)) return;
      try {
        await api(`/events/${info.event.id}`, 'DELETE');
        info.event.remove();
      } catch (e) {
        alert(`Delete failed: ${e.message}`);
      }
    },
  });

  calendar.render();
});

Register the new entry point in vite.config.js so Vite builds it:

import { defineConfig } from 'vite';
import laravel from 'laravel-vite-plugin';

export default defineConfig({
    plugins: [
        laravel({
            input: ['resources/css/app.css', 'resources/js/app.js', 'resources/js/calendar.js'],
            refresh: true,
        }),
    ],
});

Then create resources/views/calendar.blade.php. The csrf-token meta tag is what the script reads before every write request. FullCalendar v6 injects its own CSS, so no stylesheet import is required.

<!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
<head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <meta name="csrf-token" content="{{ csrf_token() }}">
    <title>Calendar</title>
    @vite('resources/js/calendar.js')
</head>
<body>
    <div id="calendar" style="max-width: 1100px; margin: 2rem auto;"></div>
</body>
</html>

Run npm run dev during development (or npm run build for production) and php artisan serve, then open /calendar.

Alternative: loading FullCalendar from a CDN

If you are not using a build step, the global bundle exposes a FullCalendar object and includes the standard plugins, interaction among them. Pin an exact version rather than "latest" so a future major release does not change behavior under you:

<script src="https://cdn.jsdelivr.net/npm/fullcalendar@6.1.15/index.global.min.js"></script>
<script>
  document.addEventListener('DOMContentLoaded', () => {
    const calendar = new FullCalendar.Calendar(document.getElementById('calendar'), {
      initialView: 'dayGridMonth',
      events: '/events',
      selectable: true,
      editable: true,
      // same select / eventDrop / eventClick handlers as above
    });
    calendar.render();
  });
</script>

How create, move and delete work

Create. The select callback receives startStr, endStr and allDay. For all-day selections these are date strings such as 2026-10-24; for time-grid selections they are full timestamps with an offset. After the server responds with the stored event, calendar.addEvent() renders it using the server's ID, which later PATCH and DELETE calls depend on.

Move and resize. eventDrop and eventResize fire after the event has already moved on screen. If the request fails, info.revert() puts it back, so the UI never shows a state the database rejected.

Delete. eventClick asks for confirmation, sends DELETE and calls info.event.remove() only after the server succeeded. For anything beyond a demo, replace prompt/confirm with a modal that also allows editing the title.

Note that FullCalendar uses an exclusive end: an all-day event from 24 to 26 October is sent with end set to the 27th. Store it as received and send it back unchanged; converting to inclusive dates in only one direction is a common source of events that grow or shrink by a day.

Time zones

Keep 'timezone' => 'UTC' in config/app.php (the default) and store everything in UTC. The controller parses incoming ISO strings with their offset and converts them with ->utc(); the model emits DATE_ATOM strings, and FullCalendar renders them in the browser's local time because its timeZone option defaults to 'local'. If every user should see the same wall-clock time regardless of location (a room booking system in one office, say), set FullCalendar's timeZone to that zone and read the timeZone documentation first, since named zones other than local and UTC are only handled accurately with one of FullCalendar's time zone plugins (Luxon or Moment Timezone).

Common pitfalls

Laravel 10 vs 11

The code is the same for both. The differences are structural: Laravel 11 has a slimmer skeleton (no app/Http/Kernel.php; middleware is configured in bootstrap/app.php) and the base Controller class is empty by default, so if you call $this->authorize() you need to add the AuthorizesRequests trait yourself or use Gate::authorize().

Summary

Store events with a nullable end and an all-day flag, expose a feed that returns events overlapping the requested start/end window, and let FullCalendar's select, eventDrop, eventResize and eventClick callbacks call validated REST endpoints with the CSRF header. Keep the database in UTC, revert the UI on failed requests, and scope events to their owner before going to production.

Read next

More in Web