Courses

Creating CRM with Filament 3: Step-By-Step

Customer Documents: Upload/Download

Summary of this lesson:
- Creating Document model with file upload functionality
- Adding document section to customer edit form
- Implementing file deletion on record removal
- Adding downloadable document links to view page

Our Customer needs some documents to be uploaded. These can range from simple information sheets to signed PDF documents. We need to be able to upload them and add notes to them, just like this:

In this lesson, we will do the following:

  • Create Documents database table, model
  • Add Documents to the Customer form - only for edit page
    • As a bonus, we will clean up the form a bit
  • Add Documents to the Customer view page as downloadable links

Creating Database Table and Model

Our Documents will have the following fields:

  • id
  • customer_id
  • file_path
  • comments (nullable text)

Let's start with the migration:

Migration

use App\Models\Customer;
 
// ...
 
Schema::create('documents', function (Blueprint $table) {
$table->id();
$table->foreignIdFor(Customer::class)->constrained();
$table->string('file_path');
$table->text('comments')->nullable();
$table->timestamps();
});

Then, we will create the model:

app/Models/Document.php

use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Storage;
 
class Document extends Model
{
protected $fillable = [
'customer_id',
'file_path',
'comments'
];
 
protected static function booted(): void
{
self::deleting(function (Document $customerDocument) {
Storage::disk('public')->delete($customerDocument->file_path);
});
}
 
public function customer(): BelongsTo
{
return $this->belongsTo(Customer::class);
}
}

Quick note: Look at our deleting observer - we delete the file from the storage when the document is deleted. This is an excellent practice to follow.

And lastly, we need to tie our Customer model to the Document model:

// ...
 
public function documents(): HasMany
{
return $this->hasMany(Document::class);
}

Adding Documents to the Customer Form

Adding Documents to the Customer form is quite easy:

app/Filament/Resources/CustomerResource.php

// ...
public static function form(Form $form): Form
{
return $form
->schema([
Forms\Components\TextInput::make('first_name')
->maxLength(255),
Forms\Components\TextInput::make('last_name')
->maxLength(255),
Forms\Components\TextInput::make('email')
->email()
->maxLength(255),
Forms\Components\TextInput::make('phone_number')
->maxLength(255),
Forms\Components\Textarea::make('description')
->maxLength(65535)
->columnSpanFull(),
Forms\Components\Select::make('lead_source_id')
->relationship('leadSource', 'name'),
Forms\Components\Select::make('tags')
->relationship('tags', 'name')
->multiple(),
Forms\Components\Select::make('pipeline_stage_id')
->relationship('pipelineStage', 'name', function ($query) {
$query->orderBy('position', 'asc');
})
->default(PipelineStage::where('is_default', true)->first()?->id),
Forms\Components\Section::make('Documents')
// This will make the section visible only on the edit page
->visibleOn('edit')
->schema([
Forms\Components\Repeater::make('documents')
->relationship('documents')
->hiddenLabel()
->reorderable(false)
->addActionLabel('Add Document')
->schema([
Forms\Components\FileUpload::make('file_path')
->required(),
Forms\Components\Textarea::make('comments'),
])
->columns()
])
]);
}
 
// ...

Adding this Section with Repeater quickly gives us the following:

But we can all agree that this form needs a bit of a face-lift, so let's do that: app/Filament/Resources/CustomerResource.php

// ...
public static function form(Form $form): Form
{
return $form
->schema([
Forms\Components\TextInput::make('first_name')
->maxLength(255),
Forms\Components\TextInput::make('last_name')
->maxLength(255),
Forms\Components\TextInput::make('email')
->email()
->maxLength(255),
Forms\Components\TextInput::make('phone_number')
->maxLength(255),
Forms\Components\Textarea::make('description')
->maxLength(65535)
->columnSpanFull(),
Forms\Components\Select::make('lead_source_id')
->relationship('leadSource', 'name'),
Forms\Components\Select::make('tags')
->relationship('tags', 'name')
->multiple(),
Forms\Components\Select::make('pipeline_stage_id')
->relationship('pipelineStage', 'name', function ($query) {
$query->orderBy('position', 'asc');
})
->default(PipelineStage::where('is_default', true)->first()?->id),
Forms\Components\Section::make('Customer Details')
->schema([
Forms\Components\TextInput::make('first_name')
->maxLength(255),
Forms\Components\TextInput::make('last_name')
->maxLength(255),
Forms\Components\TextInput::make('email')
->email()
->maxLength(255),
Forms\Components\TextInput::make('phone_number')
->maxLength(255),
Forms\Components\Textarea::make('description')
->maxLength(65535)
->columnSpanFull(),
])
->columns(),
Forms\Components\Section::make('Lead Details')
->schema([
Forms\Components\Select::make('lead_source_id')
->relationship('leadSource', 'name'),
Forms\Components\Select::make('tags')
->relationship('tags', 'name')
->multiple(),
Forms\Components\Select::make('pipeline_stage_id')
->relationship('pipelineStage', 'name', function ($query) {
$query->orderBy('position', 'asc');
})
->default(PipelineStage::where('is_default', true)->first()?->id)
])
->columns(3),
Forms\Components\Section::make('Documents')
// This will make the section visible only on the edit page
->visibleOn('edit')
->schema([
Forms\Components\Repeater::make('documents')
->relationship('documents')
->hiddenLabel()
->reorderable(false)
->addActionLabel('Add Document')
->schema([
Forms\Components\FileUpload::make('file_path')
->required(),
Forms\Components\Textarea::make('comments'),
])
->columns()
])
]);
}
 
// ...

This face-lift moved the fields around and added cleaner sections for better separation. The result is the following:

Which is much cleaner and easier to use.


Adding Documents to the Customer View Page

Last, we need a place to view the Documents. Users could go into editing, but that is not very convenient. Let's add a new tab to the Customer view page:

app/Filament/Resources/CustomerResource.php

use Filament\Infolists\Components\RepeatableEntry;
use Filament\Support\Colors\Color;
use Illuminate\Support\Facades\Storage;
 
// ...
 
public static function infoList(Infolist $infolist): Infolist
{
return $infolist
->schema([
Section::make('Personal Information')
->schema([
TextEntry::make('first_name'),
TextEntry::make('last_name'),
])
->columns(),
Section::make('Contact Information')
->schema([
TextEntry::make('email'),
TextEntry::make('phone_number'),
])
->columns(),
Section::make('Additional Details')
->schema([
TextEntry::make('description'),
]),
Section::make('Lead and Stage Information')
->schema([
TextEntry::make('leadSource.name'),
TextEntry::make('pipelineStage.name'),
])
->columns(),
Section::make('Documents')
// This will hide the section if there are no documents
->hidden(fn($record) => $record->documents->isEmpty())
->schema([
RepeatableEntry::make('documents')
->hiddenLabel()
->schema([
TextEntry::make('file_path')
->label('Document')
// This will rename the column to "Download Document" (otherwise, it's just the file name)
->formatStateUsing(fn() => "Download Document")
// URL to be used for the download (link), and the second parameter is for the new tab
->url(fn($record) => Storage::url($record->file_path), true)
// This will make the link look like a "badge" (blue)
->badge()
->color(Color::Blue),
TextEntry::make('comments'),
])
->columns()
]),
Section::make('Pipeline Stage History and Notes')
->schema([
ViewEntry::make('pipelineStageLogs')
->label('')
->view('infolists.components.pipeline-stage-history-list')
])
->collapsible()
]);
}

Opening the Customer view page will now show the Documents section (as long as you have a Document uploaded):


In the next lesson, we will add custom field support for our Customers.

Previous: Customer View Page with Infolist
avatar

before the facelift of app/Filament/Resources/CustomerResource.php you need to php artisan migrate the relationship is ->relationship('customerDocuments') (not ->relationship('documents') )

avatar

Updated the relationship name. It slipped in editing proccess and it should be documents everywhere.

avatar
You can use Markdown
avatar

i get an error in the pipeline-stage-history-list.blade.php foreach() argument must be of type array|object, null given  

avatar

Could you add some code example? There might be something wrong with the relationship or data loading

avatar

adding an if before the foreach solves the problem in the resources/views/infolists/components/pipeline-stage-history-list.blade.php

@if(!empty($getState()))

avatar

In that case, your observer has failed to create a fresh state OR you forgot to freshly migrate the database.

By default, it should have a state as soon as a customer is created by using an observer

avatar
You can use Markdown
avatar

i can see the documents section but i cann't see any document and i uploaded 2 files and i can see them if i edit the customer

avatar

There might be issue with your relationship names. Please change all of them to documents and it should work. If it does not - would be great to see some code examples.

avatar
You can use Markdown
avatar

Modestas, how about use RelationManager in place of Repeater to deal with documents? Then we can add tags, use default table search, actions and so on. How this can be done with infolists?

avatar

Personally, I would not use relation manager here. But if you do want to use it - infolist should work as is. it's just a relationship after all, and we are doing cuatom data display (from a relationship)

avatar
You can use Markdown
avatar

love the simplicity of how documents are added! Looks really clean. a few questions:

  1. I dont see any checks for filetypes, isn't that dangerous if someone uploades a php file? or is laravel /filament doing some invisible checks?
  2. more general i see that a lot of times you use the filament function to change values before they are submitted. Personally i use observers for that, are there any advantages for one over the other? My project is pure filament so far, so maybe I should use that but I like the clearness of observers.
  3. My own plan is (because i need the ability to attach documents everywhere) to make a trait out of it and the documents table polymorphic. Do you have any resources/courses that would help me with that?

Thanks again for everything and I have to say (having two small kids) that I love this text format for learning. It is perfect!

😍 1
avatar

Hi, first of all - thank you for the kind works!

Now onto your questions:

  1. We did not add any document validation intentionally as we don't know what documents people might want. For sure there should be some!
  2. There is no difference I would say. We just use filament functions to handle these things as they are easier to understand for a big audience. And honestly, less explaining on why there is an observer or something else :)
  3. For that - use spatie media library - it does what you need!
avatar

thanks for the fast reply! Quick follow up question, I am indeed considering using spatie media library but I dont need all those extra functions and need support for multiple different filetypes and I dont know if that would work.. I read the docs but couldnt find anything about enabling different file types.

Do you have any (filament) example projects with spatiemedia library planned?

avatar

With spatie media library you can upload whatever you want. I've used it in many projects to upload documents, invoices and so on!

As for example, not sure right now (replying from mobile :) ) but we should have some examples of media upload overall

avatar

Thanks Modestas! Thats great to hear, will try it out. :)

avatar
You can use Markdown
avatar

If your uploaded files are not found, you may not have a symbolic link from your public folder to your storage folder. You can run the following to fix it:

php artisan storage:link
👍 1
avatar
You can use Markdown
avatar
You can use Markdown