Guide to Integrate FullCalendar in Laravel Filament Widgets

Laravel Filament provides a fantastic administrative panel builder for Laravel applications. When it comes to displaying dynamic events, tasks, or schedules, integrating a robust calendar solution like FullCalendar is a common requirement. This post will guide you through adding FullCalendar to your Laravel Filament widgets, highlighting key configurations and, crucially, providing solutions to common errors you might encounter.

Why FullCalendar in Laravel Filament?

FullCalendar is a versatile JavaScript calendar library that allows you to create interactive and customizable calendars. Integrating it into Filament widgets empowers you to build powerful dashboards for:

  • Event Management: Displaying conferences, webinars, or social events.
  • Task Scheduling: Visualizing project timelines and deadlines.
  • Resource Booking: Showing availability of rooms, equipment, or staff.
  • Appointment Management: For clinics, salons, or service-based businesses.

The easiest way to achieve this integration is by using the excellent saade/filament-fullcalendar plugin.

Prerequisites

  • Laravel v10+ Installed
  • Filament v3+ Installed with Panel builder

Step 1: Installation and Basic Setup

First, ensure you have a Laravel Filament project set up. Then, install the fullcalendar plugin via Composer:

composer require saade/filament-fullcalendar:^3.0

After installation, this plugin needs to be registered in your Filament panel provider, typically app/Providers/Filament/AdminPanelProvider.php:

use Saade\FilamentFullCalendar\FilamentFullCalendarPlugin;
use Filament\Panel;
use Filament\PanelProvider;

class AdminPanelProvider extends PanelProvider
{
    public function panel(Panel $panel): Panel
    {
        return $panel
            // ... other panel configurations
            ->plugins([
                FilamentFullCalendarPlugin::make()
                    // You can add global configurations here, e.g., ->selectable(true)
            ]);
    }
}

Step 2: Implementing the Calendar Widget

Now, generate a new Filament widget for your calendar:

php artisan make:filament-widget CalendarWidget

It will create a file app/Filament/Widgets/CalendarWidget.php. Update this file to extend Saade\FilamentFullCalendar\Widgets\FullCalendarWidget.

namespace App\Filament\Widgets;

use Saade\FilamentFullCalendar\Widgets\FullCalendarWidget;
use Illuminate\Database\Eloquent\Model; // If you're fetching from a model
use App\Models\Event; // Example: Replace with your event model

class CalendarWidget extends FullCalendarWidget
{
    // If you have a dedicated model for events, you can specify it here.
    // This allows for default create/edit/delete actions.
    public Model | string | null $model = Event::class; // Replace 'Event::class' with your actual model

    /**
     * FullCalendar will call this function whenever it needs new event data.
     * This is triggered when the user clicks prev/next or switches views on the calendar.
     *
     * @param array $fetchInfo An array containing 'start', 'end', and 'timezone' for the current view.
     * @return array
     */
    public function fetchEvents(array $fetchInfo): array
    {
        // Example: Fetch events from your 'Event' model within the visible date range
        return Event::query()
            ->where('start_date', '>=', $fetchInfo['start']) // Assuming 'start_date' and 'end_date' columns
            ->where('end_date', '<=', $fetchInfo['end'])
            ->get()
            ->map(fn (Event $event) => [
                'id' => $event->id,
                'title' => $event->name, // Replace with your event title column
                'start' => $event->start_date, // Replace with your event start date column
                'end' => $event->end_date,     // Replace with your event end date column
                // You can add more FullCalendar event properties here
                // 'url' => EventResource::getUrl(name: 'view', parameters: ['record' => $event]),
                // 'shouldOpenUrlInNewTab' => true,
                // 'color' => '#f00',
            ])
            ->toArray();
    }

    /**
     * You can customize FullCalendar options by overriding the config method.
     */
    public function config(): array
    {
        return [
            'initialView' => 'dayGridMonth',
            'headerToolbar' => [
                'left' => 'prev,next today',
                'center' => 'title',
                'right' => 'dayGridMonth,timeGridWeek,timeGridDay',
            ],
            // ... more FullCalendar options
            // 'editable' => true, // Enable dragging and resizing if your model allows
            // 'selectable' => true, // Enable date selection to create new events
        ];
    }
}

Key Components:

  • $model Property: Crucial for the plugin’s built-in actions (create, edit, view, delete) to understand which database model your events belong to.
use App\Models\Event; // Make sure your Event model exists

class CalendarWidget extends FullCalendarWidget
{
    public Model | string | null $model = Event::class;
    // ...
}
  • fetchEvents(array $fetchInfo): This method is called by FullCalendar to retrieve event data for the currently displayed date range. You should query your database and return an array of event objects, each with at least id, title, start, and end properties.
public function fetchEvents(array $fetchInfo): array
{
    return Event::query()
        ->where('start_date', '>=', $fetchInfo['start'])
        ->where('end_date', '<=', $fetchInfo['end'])
        ->get()
        ->map(fn (Event $event) => [
            'id' => $event->id,
            'title' => $event->name,
            'start' => $event->start_date,
            'end' => $event->end_date,
            // Add more properties like 'color', 'classNames', 'url' etc.
        ])
        ->toArray();
}
  • config(): array: This method is where you configure all of FullCalendar’s JavaScript options for your specific widget instance (e.g., initial view, toolbar buttons, event display format, etc.).
public function config(): array
{
    return [
        'initialView' => 'dayGridMonth',
        'headerToolbar' => [
            'left' => 'prev,next today',
            'center' => 'title',
            'right' => 'dayGridMonth,timeGridWeek,timeGridDay',
        ],
        // 'selectable' => true, // Allows clicking/dragging to select dates
        // 'editable' => true,   // Allows dragging/resizing events
        // ... many more FullCalendar options
    ];
}

Step 3: Final Touches

Add the Widget: Ensure your CalendarWidget::class is listed in the widgets options of your app/Providers/Filament/AdminPanelProvider.php to display it on the dashboard.

use App\Filament\Widgets\CalendarWidget;
use Saade\FilamentFullCalendar\FilamentFullCalendarPlugin;
use Filament\Panel;
use Filament\PanelProvider;

class AdminPanelProvider extends PanelProvider
{
    public function panel(Panel $panel): Panel
    {
        return $panel
            // ... other panel configurations
            ->widgets([
                CalendarWidget::class, // add your calendar widget class here
            ])
            ->plugins([
                FilamentFullCalendarPlugin::make()
                    // You can add global configurations here, e.g., ->selectable(true)
            ]);
    }
}

Composer Autoload & Cache: After any class changes or file movements, always run the following commands:

composer dump-autoload
php artisan optimize:clear
php artisan filament:clear-cached-components # Useful for Filament-specific cache

Verify Data: Use dd() liberally in your fetchEvents() and mountUsing() methods to inspect the data being passed, especially id values, to ensure they match your database primary keys. So, you can extend the development to open event view panel on any event click.

Conclusion

By following these steps, you can successfully integrate and customize FullCalendar within your FilamentPHP widgets, creating a powerful and interactive event management dashboard. For more advanced configurations, always refer to the official FullCalendar documentation
and the saade/filament-fullcalendar plugin’s GitHub repository.

Add Missing Migrations to Laravel Database Without executing

Learn how to add missing Laravel migration entries to the database without re-running them. Perfect for restoring synced environments or imported databases.

Sometimes, when working with Laravel projects, especially when migrating databases manually or syncing environments, the migrations table might miss entries — even though the migration files exist and were executed. This causes Laravel to attempt to re-run migrations or show them as pending. And even sometime, you added new migration and try to migrate them, but migration gives error that previous migrations are pending.

This article will guide you through creating an Artisan command that adds missing migration entries to the database without running them, keeping Laravel in sync with the actual DB schema.

Step 1: Create the Command

To create an artisan command, run the following command in your terminal:

php artisan make:command SyncMigrations

It creates a file at app/Console/Commands/SyncMigrations.php.

Step 2: Add the Logic

Now, open this file and paste the following code inside it:

<?php

namespace App\Console\Commands;

use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\File;

class SyncMigrations extends Command
{
    protected $signature = 'sync:migrations';
    protected $description = 'Add missing migrations to the migrations table without running them';

    public function handle()
    {
        $migrationPath = database_path('migrations');
        $files = File::files($migrationPath);

        $fileMigrations = collect($files)->map(function ($file) {
            return pathinfo($file->getFilename(), PATHINFO_FILENAME);
        });

        $existingMigrations = DB::table('migrations')->pluck('migration');

        $missingMigrations = $fileMigrations->diff($existingMigrations);

        if ($missingMigrations->isEmpty()) {
            $this->info('All migrations are already recorded.');
            return 0;
        }

        $lastBatch = DB::table('migrations')->max('batch') ?? 0;
        $nextBatch = $lastBatch + 1;

        foreach ($missingMigrations as $migration) {
            DB::table('migrations')->insert([
                'migration' => $migration,
                'batch' => $nextBatch,
            ]);
            $this->line("Added migration: <info>$migration</info>");
        }

        $this->info("Added " . $missingMigrations->count() . " missing migration(s) to batch $nextBatch.");
        return 0;
    }
}

Explanation:

  • Get all filenames list from the migrations folder.
  • Get all migrations list from the migrations table.
  • Check a difference between two lists and return 0 if no difference found.
  • If there is a difference, get a last migration batch and create new batch by adding 1 to this batch number.
  • Add all migrations available in the difference with new batch number.

Step 3: Run the Command

If you are running Laravel < 11, register the command file in app/Console/Kernel.php:

protected $commands = [
    \App\Console\Commands\SyncMigrations::class,
];

In Laravel 11+, there’s no need to register commands manually.

Now, run the following command in your terminal:

php artisan sync:migrations

You’ll see output like this:

Added migration: 2024_12_31_235959_create_users_table
Added migration: 2025_01_01_000000_create_orders_table
Added 2 missing migration(s) to batch 4.

Caution

This command does not execute the migrations. It assumes the database schema already reflects them. Use this only when you’re sure the migrations were already applied, e.g. from another environment or a database import.

Conclusion

This approach is a safe and Laravel-friendly way to fix out-of-sync migrations. It’s perfect for developers working across multiple environments or restoring production databases.

Want to take it further? Add a prompt or backup feature to this command. Let me know in the comments!

How to Integrate Sentry with CodeIgniter 3

Learn how to integrate Sentry error tracking with CodeIgniter 3 to monitor and debug PHP application issues in real-time.

Sentry is a powerful error-tracking tool that helps you monitor and fix crashes in real-time. In this post, we’ll walk through how to integrate Sentry with a CodeIgniter 3 application and log all errors to sentry.

Prerequisites

Before you begin, make sure you have:

  • A Sentry account with a created project.
  • A working CodeIgniter 3 setup.
  • PHP 7.2+ (recommended).
  • Composer installed.

Step 1: Install Sentry SDK via Composer

Open your terminal and run:

composer require sentry/sentry

If you haven’t initialized Composer yet in your CI3 project, run composer init first.

Step 2: Enable and Configure Hooks

To enable hook in CodeIgniter 3, Edit your application/config/config.php and update the 'enable_hooks' variable to TRUE if it is FALSE.

$config['enable_hooks'] = TRUE;

Now, register the sentry hook into the application/config/hooks.php file:

$hook['pre_system'][] = array(
    'class'    => '',
    'function' => 'init_sentry', // Function to be called
    'filename' => 'sentry.php', // Filename of the hook
    'filepath' => 'hooks'
);

As mentioned in the hook file, create application/hooks/sentry.php and add the following code:

use Sentry\ClientBuilder;
use Sentry\State\Hub;

function init_sentry()
{
    require_once APPPATH . '../vendor/autoload.php';

    \Sentry\init([
        'dsn' => 'https://your-dsn@sentry.io/project-id',
        'environment' => ENVIRONMENT,
        'error_types' => E_ALL & ~E_NOTICE, // Adjust as needed
    ]);
}

Replace 'https://your-dsn@sentry.io/project-id' with your actual Sentry DSN.

Step 3: Capture Errors or Messages

Now, you can log errors to sentry using multiple ways as follows,

Manually Capture Exceptions

try {
    // Your code here
} catch (Exception $e) {
    \Sentry\captureException($e);
}

Manually Capture Messages

\Sentry\captureMessage('Something happened!', \Sentry\Severity::warning());

Step 4: Automatically Capture Uncaught Exceptions

You can log all uncaught exceptions by extending the CI Exception class.

Create a new exception file at application/core/MY_Exceptions.php and add the following content in it:

class MY_Exceptions extends CI_Exceptions
{
    public function show_exception($exception)
    {
        if (class_exists('\Sentry\State\Hub')) {
            \Sentry\captureException($exception);
        }

        return parent::show_exception($exception);
    }
}

This will overwrite the codeigniter exception to log errors in sentry.

Step 5: Test the Integration

To test the integration, add the following code to generate the fake exception:

throw new Exception("Testing Sentry in CI3");

You should see this exception appear in your Sentry dashboard almost immediately.

Pro Tips

  • Use .env files or config variables to store your DSN securely.
  • Configure environments like development, production, staging in the environment key of the config.
  • You can even capture user context (like logged-in user ID or email) with Sentry.

Conclusion

With this setup, your CodeIgniter 3 project is now integrated with Sentry for powerful real-time error tracking. From catching uncaught exceptions to manually logging messages, Sentry gives you the tools you need to debug faster and ship more reliably.

Have questions or need help capturing user context? Drop a comment below!

Implementing JWT Authentication in CodeIgniter 3

Learn how to implement secure JWT authentication in CodeIgniter 3. Step-by-step guide for token generation, validation, and integration.

Securing your mobile API is critical in modern applications. In this guide, we’ll walk through how to implement JWT (JSON Web Token) based authentication in CodeIgniter 3, including access token and refresh token support for long-lived sessions.

Overview of JWT Auth Flow

Here’s the standard flow:

  1. User logs in → server returns an access token and a refresh token.
  2. Mobile app uses the access token in the Authorization header for every request.
  3. When access token expires, the app sends the refresh token to get a new access token.

Prerequisites

  • CodeIgniter 3 installed
  • firebase/php-jwt JWT library via Composer
  • users table for authentication and user_tokens table for refresh tokens

Step 1: Install JWT Library

Use composer to install JWT library as follows:

composer require firebase/php-jwt

Step 2: Create JWT Helper Class

Create a JWT helper class file at application/libraries/Authorization_Token.php and add the following code to it:

use Firebase\JWT\JWT;
use Firebase\JWT\Key;

class Authorization_Token {
    private $CI;
    private $token_key;

    public function __construct() {
        $this->CI =& get_instance();
        $this->token_key = 'YOUR_SECRET_KEY';
    }

    public function generateToken($user_data) {
        $issuedAt = time();
        $expirationTime = $issuedAt + 3600; // 1 hour
        $payload = [
            'iat' => $issuedAt,
            'exp' => $expirationTime,
            'data' => $user_data
        ];
        return JWT::encode($payload, $this->token_key, 'HS256');
    }

    public function validateToken() {
        $headers = apache_request_headers();
        if (!isset($headers['Authorization'])) return false;
        
        $token = str_replace('Bearer ', '', $headers['Authorization']);
        try {
            $decoded = JWT::decode($token, new Key($this->token_key, 'HS256'));
            return (array) $decoded->data;
        } catch (Exception $e) {
            return false;
        }
    }
}

Step 3: Create Login API

Create a user_tokens table for storing refresh tokens.

CREATE TABLE user_tokens (
    id INT AUTO_INCREMENT PRIMARY KEY,
    user_id INT NOT NULL,
    refresh_token VARCHAR(255) NOT NULL,
    expires_at DATETIME NOT NULL,
    created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);

Create a login API file anywhere inside app/controllers folder and add the following content inside it.

class Auth extends CI_Controller {
    public function login_post()
    {
        $email = $this->post('email');
        $password = $this->post('password');

        $user = $this->db->get_where('users', ['email' => $email])->row();

        if (!$user || !password_verify($password, $user->password)) {
            return $this->response(['status' => false, 'message' => 'Invalid credentials'], 401);
        }

        $this->load->library('Authorization_Token', null, 'authToken');
        $access_token = $this->authToken->generateToken(['id' => $user->id, 'email' => $user->email]);

        $refresh_token = bin2hex(random_bytes(64));
        $this->db->insert('user_tokens', [
            'user_id' => $user->id,
            'refresh_token' => $refresh_token,
            'expires_at' => date('Y-m-d H:i:s', strtotime('+30 days'))
        ]);

        return $this->response([
            'status' => true,
            'access_token' => $access_token,
            'refresh_token' => $refresh_token,
        ], 200);
    }
}

Step 4: Protect API Routes

Create a base controller file BaseApi_Controller.php inside app/controllers folder. Add the following code to base controller file.

class BaseApi_Controller extends REST_Controller
{
    public $user_data;

    public function __construct()
    {
        parent::__construct();
        $this->load->library('Authorization_Token', null, 'authToken');
        $user_data = $this->authToken->validateToken();

        if (!$user_data) {
            $this->response([
                'status' => false,
                'message' => 'Access token expired',
                'token_expired' => true
            ], 401);
            exit;
        }

        $this->user_data = $user_data;
    }
}

This file handles token validations for all requests. But, it will not automatically intercept all requests. So, all your secure API files need to extend this BaseApi_Controller.

class Orders extends Authenticated_Controller {
    public function list_get() {
        $user_id = $this->user_data['id'];
        $orders = $this->db->get_where('orders', ['user_id' => $user_id])->result();

        $this->output
            ->set_content_type('application/json')
            ->set_output(json_encode($orders));
    }
}

Step 5: Token Refresh

Create new api file AuthController.php for refresh token and add the following code in it.

class Auth extends CI_Controller {
    public function refresh_token_post()
    {
        $refresh_token = $this->post('refresh_token');

        $token_data = $this->db->get_where('user_tokens', [
            'refresh_token' => $refresh_token
        ])->row();

        if (!$token_data || strtotime($token_data->expires_at) < time()) {
            return $this->response([
                'status' => false,
                'message' => 'Invalid or expired refresh token'
            ], REST_Controller::HTTP_UNAUTHORIZED);
        }

        // Generate new access token
        $this->load->library('Authorization_Token', null, 'authToken');
        $access_token = $this->authToken->generateToken([
            'id' => $token_data->user_id,
            'email' => 'user@email.com' // Fetch if needed
        ]);

        return $this->response([
            'status' => true,
            'access_token' => $access_token,
            'expires_in' => 900
        ], REST_Controller::HTTP_OK);
    }
}

Summary

  • JWT access tokens: short-lived (e.g., 15 minutes)
  • Refresh tokens: long-lived (e.g., 30 days), stored securely
  • On access token expiry: client uses refresh token to get a new one
  • REST_Controller is used to simplify JSON responses in CodeIgniter 3

Final Thoughts

Implementing access and refresh tokens properly ensures secure and scalable mobile API sessions. Using CodeIgniter 3 with JWT and refresh tokens gives you full control over session lifecycle, security, and logout behavior.

Handling File Uploads in Different Python Frameworks

Learn how to handle file uploads in popular Python frameworks like Flask, Django, FastAPI, and Frappe. Step-by-step guide with examples. Perfect for developers!

To handle file uploads from an HTML form in your Python Frameworks, you typically handle them differently depending on the web framework you’re using (e.g., Flask, Django, FastAPI, Frappe, etc.).

Note: HTML form must include enctype="multipart/form-data".

In this article, we learn how to handle file uploads in some common Python web frameworks:

Using Flask

Design an HTML form with file field as follows,

<form action="/upload" method="post" enctype="multipart/form-data">
  <input type="file" name="myfile">
  <input type="submit">
</form>

You can get the uploaded file from the myfile field as follows,

from flask import Flask, request

app = Flask(__name__)

@app.route('/upload', methods=['POST'])
def upload_file():
    uploaded_file = request.files['myfile']
    if uploaded_file.filename != '':
        uploaded_file.save(f"./uploads/{uploaded_file.filename}")
    return "File uploaded successfully!"

Using Django

In Django, HTML form is exactly same. But, you have to add csrf_token to each form.

<form method="post" enctype="multipart/form-data">
  {% csrf_token %}
  <input type="file" name="myfile">
  <input type="submit">
</form>

You can get the file from the myfile field as follows,

from django.core.files.storage import FileSystemStorage
from django.http import HttpResponse

def upload_file(request):
    if request.method == 'POST' and request.FILES['myfile']:
        uploaded_file = request.FILES['myfile']
        fs = FileSystemStorage()
        fs.save(uploaded_file.name, uploaded_file)
        return HttpResponse('File uploaded!')
    return HttpResponse('Upload form')

Get book to learn basics of python from https://amzn.to/3FIs9cW

Using FastAPI

The HTML design file is same as the Django HTML file with csrf_token.

Backend code to get the uploaded file is as follows,

from fastapi import FastAPI, File, UploadFile
from fastapi.responses import HTMLResponse

app = FastAPI()

@app.post("/upload/")
async def upload_file(file: UploadFile = File(...)):
    contents = await file.read()
    with open(f"./uploads/{file.filename}", "wb") as f:
        f.write(contents)
    return {"filename": file.filename}

Using Frappe

In Frappe, when you submit a file from an HTML form, you can handle file uploads using its built-in frappe.utils.file_manager.save_file() method. The HTML design file for Frappe is as follows,

<form method="POST" enctype="multipart/form-data" action="/api/method/my_app.api.upload_file">
  <input type="file" name="file">
  <input type="submit" value="Upload">
</form>

In Frappe backend, you have to create whitelisted method for handling file upload as follows,

import frappe
from frappe.utils.file_manager import save_file
from frappe import _

@frappe.whitelist(allow_guest=True)  # or remove `allow_guest=True` if auth is needed
def upload_file():
    # Access the uploaded file from the request
    uploaded_file = frappe.request.files.get('file')
    
    if not uploaded_file:
        frappe.throw(_("No file uploaded"))

    # Save the file using Frappe's file manager
    saved_file = save_file(
        filename=uploaded_file.filename,
        content=uploaded_file.stream.read(),
        dt=None,  # You can pass doctype here if you want to attach it
        dn=None   # and document name
    )

    return {
        "message": "File uploaded successfully",
        "file_url": saved_file.file_url,
        "file_name": saved_file.file_name
    }

Conclusion

In above post, we learned file uploads using four different Python backed frameworks. We learned that different framework have different ways to handle file uploads.

Disclaimer: This post contains affiliate links. If you use these links to buy something, I may earn a commission at no extra cost to you.