arrow_back Back to Course |

Personal finance with Laravel and Filament

Lesson 5 / 7
Lesson 5

Creating Transactions in Filament

It’s time to get our hands dirty with the heart of any finance application: transactions. In this lesson, we’ll build a complete Transaction resource in Filament—with dedicated pages for creating and editing, proper data scoping, and a slick income/expense toggle that will make entering data a pleasure.

Creating the Transaction Resource

Unlike our previous resources that used the --simple flag for modal-based CRUD, transactions deserve their own dedicated pages. This gives us more room to breathe and a better user experience.

bash
php artisan make:filament-resource Transaction

This command generates the full resource structure with separate pages for listing, creating, and editing. You can see the initial setup in this commit.

Scoping Transactions to the Authenticated User

Just like our other resources, we need to ensure users can only see and manage their own transactions. However, since we have multiple pages now, we need to apply scoping in a few places.

Scoping the Query

In TransactionResource.php, we override getEloquentQuery() to filter by the authenticated user:

php
public static function getEloquentQuery(): \Illuminate\Database\Eloquent\Builder
{
    return parent::getEloquentQuery()->where('user_id', auth()->id());
}

Automatically Assigning the User ID

When creating a transaction, we don’t want users to select themselves—we assign the user_id automatically. In CreateTransaction.php:

php
public function mutateFormDataBeforeCreate(array $data): array
{
    $data['user_id'] = auth()->id();

    return $data;
}

Users should only see their own bank accounts, categories, and budgets in the dropdown menus. We apply a filter directly in the relationship callback:

php
Select::make('bank_account_id')
    ->relationship('bankAccount', 'name', fn ($query) => $query->where('user_id', auth()->id()))
    ->required(),
Select::make('category_id')
    ->relationship('category', 'name', fn ($query) => $query->where('user_id', auth()->id())),
Select::make('budget_id')
    ->relationship('budget', 'name', fn ($query) => $query->where('user_id', auth()->id())),

You can review all the scoping changes in this commit.

Improving the Form Experience

After creating our first few transactions, we notice some friction. The field order isn’t intuitive, and we get sent to the edit page after creation instead of back to the list.

Reordering Form Fields

A logical order makes data entry faster. Let’s put the most important fields first:

php
return $schema
    ->components([
        DatePicker::make('date')
            ->required(),
        TextInput::make('amount')
            ->required()
            ->numeric(),
        TextInput::make('description')
            ->required(),
        Select::make('bank_account_id')
            ->relationship('bankAccount', 'name', fn ($query) => $query->where('user_id', auth()->id()))
            ->required(),
        Select::make('category_id')
            ->relationship('category', 'name', fn ($query) => $query->where('user_id', auth()->id())),
        Select::make('budget_id')
            ->relationship('budget', 'name', fn ($query) => $query->where('user_id', auth()->id())),
        Textarea::make('note')
            ->columnSpanFull(),
    ]);

Redirecting After Creation

By default, Filament redirects to the edit page after creating a record. For transactions, returning to the list makes more sense. In both CreateTransaction.php and EditTransaction.php:

php
protected function getRedirectUrl(): string
{
    return $this->getResource()::getUrl('index');
}

See these improvements in this commit.

Polishing the List View

The table view needs some love too. We want better column ordering, proper currency formatting, and quick actions.

Formatting and Reordering Columns

php
return $table
    ->columns([
        TextColumn::make('date')
            ->date()
            ->sortable(),
        TextColumn::make('amount')
            ->money('EUR')
            ->sortable(),
        TextColumn::make('description')
            ->searchable(),
        TextColumn::make('bankAccount.name')
            ->searchable(true),
        TextColumn::make('category.name')
            ->searchable(true),
        TextColumn::make('budget.name')
            ->searchable(true),
        TextColumn::make('note')
            ->toggleable(isToggledHiddenByDefault: true),
        // ... timestamps
    ]);

Adding Row Actions

Instead of just bulk actions, let’s add edit and delete buttons directly on each row:

php
->recordActions([
    EditAction::make(),
    DeleteAction::make(),
])

Check out these table improvements in this commit and this commit.

The Income/Expense Toggle

Now for the star of the show! Instead of manually entering negative amounts for expenses, let’s create an intuitive toggle that handles the sign automatically.

Transaction form with income/expense toggle

Building the Toggle

We use Filament’s ToggleButtons component with icons and colors to make the choice obvious:

php
use Filament\Forms\Components\ToggleButtons;
use Filament\Schemas\Components\Group;
use Filament\Schemas\Components\Utilities\Get;

Group::make([
    ToggleButtons::make('transaction_type')
        ->label('Type')
        ->options([
            'expense' => 'Expense',
            'income' => 'Income',
        ])
        ->icons([
            'expense' => 'heroicon-o-minus-circle',
            'income' => 'heroicon-o-plus-circle',
        ])
        ->colors([
            'expense' => 'danger',
            'income' => 'success',
        ])
        ->default('expense')
        ->inline()
        ->required()
        ->afterStateHydrated(function (ToggleButtons $component, $state, $record) {
            if ($record && $record->amount !== null) {
                $component->state($record->amount >= 0 ? 'income' : 'expense');
            }
        })
        ->live(),
    TextInput::make('amount')
        ->label('Amount')
        ->required()
        ->numeric()
        ->minValue(0)
        ->prefix(fn (Get $get) => $get('transaction_type') === 'income' ? '+' : '-')
        ->afterStateHydrated(function (TextInput $component, $state) {
            if ($state !== null) {
                $component->state(abs($state));
            }
        })
        ->dehydrateStateUsing(function ($state, Get $get) {
            $amount = abs((float) $state);

            return $get('transaction_type') === 'income' ? $amount : -$amount;
        }),
])->columns(2),

The magic happens in three places:

  1. afterStateHydrated on the toggle: When editing, it reads the current amount and sets the toggle accordingly.
  2. afterStateHydrated on the amount: Displays the absolute value so users always see positive numbers.
  3. dehydrateStateUsing: Before saving, it converts the amount to positive or negative based on the toggle selection.

Visual Feedback in the Table

Let’s make the amount column clearly show income vs. expense with badges and colors:

php
TextColumn::make('amount')
    ->money('EUR')
    ->sortable()
    ->badge()
    ->color(fn ($state): string => $state >= 0 ? 'success' : 'danger')
    ->icon(fn ($state): string => $state >= 0 ? 'heroicon-o-arrow-up' : 'heroicon-o-arrow-down'),

We also add a filter to quickly view only income or expenses:

php
SelectFilter::make('transaction_type')
    ->label('Tipo')
    ->options([
        'income' => 'Entrate',
        'expense' => 'Uscite',
    ])
    ->query(function (Builder $query, array $data): Builder {
        return $query
            ->when(
                $data['value'] === 'income',
                fn (Builder $query): Builder => $query->where('amount', '>=', 0),
            )
            ->when(
                $data['value'] === 'expense',
                fn (Builder $query): Builder => $query->where('amount', '<', 0),
            );
    }),

See the complete toggle implementation in this commit.

Making Categories and Budgets Optional

Not every transaction needs a category or budget. Let’s add a migration to make these fields nullable:

php
Schema::table('transactions', function (Blueprint $table) {
    $table->foreignId('category_id')->nullable()->change();
    $table->foreignId('budget_id')->nullable()->change();
});

And remove the ->required() from the form fields.

Transactions dashboard with color-coded amounts

Testing the Transaction Resource

Finally, let’s ensure everything works correctly with comprehensive tests. We need to verify:

  • Users can only see their own transactions
  • Users can create, edit, and delete their transactions
  • Users cannot access other users’ transactions
  • Users cannot create transactions for other users’ bank accounts
php
it('cannot see other users transactions', function () {
    $user = User::factory()->create();
    $otherUser = User::factory()->create();
    $otherTransaction = Transaction::factory()->for($otherUser)->create();

    Livewire::actingAs($user)
        ->test(ListTransactions::class)
        ->assertCanNotSeeTableRecords([$otherTransaction]);
});

it('can create transaction', function () {
    $user = User::factory()->create();
    $bankAccount = BankAccount::factory()->for($user)->create();

    Livewire::actingAs($user)
        ->test(CreateTransaction::class)
        ->fillForm([
            'date' => '2026-01-30',
            'transaction_type' => 'expense',
            'amount' => '150.00',
            'description' => 'Test Transaction',
            'bank_account_id' => $bankAccount->id,
        ])
        ->call('create')
        ->assertHasNoFormErrors();

    assertDatabaseHas('transactions', [
        'user_id' => $user->id,
        'bank_account_id' => $bankAccount->id,
        'description' => 'Test Transaction',
        'amount' => -15000, // 150.00 * 100, negative for expense
    ]);
});

it('cannot create transaction for other user bank account', function () {
    $user = User::factory()->create();
    $otherUser = User::factory()->create();
    $otherBankAccount = BankAccount::factory()->for($otherUser)->create();

    Livewire::actingAs($user)
        ->test(CreateTransaction::class)
        ->fillForm([
            'date' => '2026-01-30',
            'transaction_type' => 'expense',
            'amount' => '150.00',
            'description' => 'Test Transaction',
            'bank_account_id' => $otherBankAccount->id,
        ])
        ->call('create')
        ->assertHasFormErrors(['bank_account_id']);
});

View the complete test suite in this commit.

Don’t Forget to Format!

As always, keep your code clean and consistent:

bash
./vendor/bin/pint

Wrapping Up

We’ve built a fully functional Transaction resource with:

  • Dedicated pages for a better UX
  • Proper scoping to ensure data privacy
  • An intuitive income/expense toggle
  • Beautiful visual feedback in the table
  • Comprehensive tests for peace of mind

Your personal finance app is really taking shape. Happy coding!

quiz

Knowledge Check

1. Why do we use a dedicated page for creating transactions instead of a modal (--simple flag)?

2. What method do we override to automatically assign the user_id when creating a transaction?

3. How do we ensure users can only see their own bank accounts, categories, and budgets in the transaction form?

4. What is the purpose of the dehydrateStateUsing method on the amount field?