In this blog post, we will walk through building a Laravel Livewire component that allows you to execute terminal commands from within your Laravel application. We'll leverage the Symfony Process component to handle the command execution. This component will also store the command history in the database for future reference.
Laravel: Ensure you have a Laravel project set up. If not, you can create one using the following command:
composer create-project --prefer-dist laravel/laravel blogcomposer require livewire/livewire
php artisan livewire:publish --configStep 1: Setting Up the Environment
php artisan make:livewire Admin/Terminal/ManageTerminalStep 2: Creating the Command History Model and Migration
php artisan make:model CommandHistory -mpublic function up()
{
Schema::create('command_histories', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained()->onDelete('cascade');
$table->text('command');
$table->text('output')->nullable();
$table->text('error')->nullable();
$table->boolean('success');
$table->timestamps();
});
}php artisan migrateStep 3: Implementing the Livewire Component Logic
<?php
namespace App\Livewire\Admin\Terminal;
use App\Http\Traits\ImageUploadTrait;
use App\Models\CommandHistory;
use Livewire\Component;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Session;
use Livewire\Attributes\On;
use Symfony\Component\Process\Process;
use Symfony\Component\Process\Exception\ProcessFailedException;
use Livewire\Attributes\Url;
class ManageTerminal extends Component
{
use ImageUploadTrait;
public $command;
public $output;
public $success;
public $error;
public $timestamp;
public $tip;
#[Url]
public $search = '';
public function render()
{
$search = trim($this->search);
$commandHistories = CommandHistory::where('command', 'like', '%' . $search . '%')->latest()->get();
return view('livewire.admin.terminal.manage-terminal', ['commandHistories' => $commandHistories]);
}
public function executeCommand()
{
$this->validate([
'command' => 'required'
]);
try {
// Get the command from the request
Session::forget('command');
$command = trim($this->command);
Session::put('command', $command);
// Update this path to your PHP installation path
$phpPath = env('IMPORT_PHP_PATH') ?? 'D:\laravel9\php\php.exe';
// Prepend the PHP path to the command
if (strpos($command, 'php') === 0) {
$command = str_replace('php', '"' . $phpPath . '"', $command);
}
// Define the working directory to the root of your Laravel project
$workingDirectory = base_path();
$env = array_merge($_ENV, [
'APP_ENV' => env('APP_ENV'),
'APP_KEY' => env('APP_KEY'),
'DB_CONNECTION' => env('DB_CONNECTION'),
'DB_HOST' => env('DB_HOST'),
'DB_PORT' => env('DB_PORT'),
'DB_DATABASE' => env('DB_DATABASE'),
'DB_USERNAME' => env('DB_USERNAME'),
'DB_PASSWORD' => env('DB_PASSWORD'),
]);
// Execute the command using Symfony Process component
$process = Process::fromShellCommandline($command, $workingDirectory );
// $process = new Process([$command], $workingDirectory, $env);
$process->setInput("Yes\n");
// Run the command
$process->run();
// Check if the process was successful
if (!$process->isSuccessful()) {
throw new ProcessFailedException($process);
}
$this->output = $process->getOutput();
$this->success = $process->isSuccessful();
// $this->success = true;
$this->timestamp = now()->format('Y-m-d H:i:s');
$this->tip = 'For more commands, visit the documentation or contact support if you encounter any issues.';
// Get the output of the command
$commandHistory = new CommandHistory();
$commandHistory->user_id = auth()->id();
$commandHistory->command = $command;
$commandHistory->output = $this->success ? $this->output : null;
$commandHistory->error = $this->success ? null : $process->getErrorOutput();
$commandHistory->success = $this->success;
$commandHistory->save();
$this->reset(['command']);
$this->myAlert([
'message' => 'Command executed successfully',
'alert-type' => 'success'
]);
} catch (\Exception $e) {
// Handle exceptions (e.g., ProcessFailedException)
Log::error('Error executing command: ' . $e->getMessage());
$this->error = $e->getMessage();
$this->success = false;
$this->output = null;
$this->timestamp = now()->format('Y-m-d H:i:s');
$this->tip = 'For more commands, visit the documentation or contact support if you encounter any issues.';
$this->myAlert([
'message' => 'An error occurred',
'alert-type' => 'error'
]);
}
}
public function delete($rowId)
{
$commad = CommandHistory::findOrFail($rowId);
$commad->delete();
$this->myAlert([
'message' => 'Deleted successfully',
'alert-type' => 'error'
]);
}
public function copyCommand($id)
{
$commandHistory = CommandHistory::findOrFail($id);
$phpPath = env('IMPORT_PHP_PATH');
$command = str_replace($phpPath, 'php', $commandHistory->command);
$command = str_replace('"php"', 'php', $command);
$this->dispatch('copySelectedCommand', ['commandName' => trim($command)]);
}
#[On('copiedSuccess')]
public function showAlert($copiedText)
{
try {
// Replace 'php artisan' with empty string
$copiedCmd = str_replace('php artisan', '', $copiedText);
// Show success alert
$this->myAlert([
'message' => $copiedCmd . ' copied successfully.',
'alert-type' => 'success'
]);
} catch (\Exception $e) {
// Handle any exceptions here
$this->myAlert([
'message' => 'An error occurred while processing the copied text.',
'alert-type' => 'error'
]);
}
}
public $commandRowId = [];
public function checkAll()
{
$getPageIds = CommandHistory::get();
if ($getPageIds) {
$this->commandRowId = $getPageIds->pluck('id')->toArray();
} else {
$this->commandRowId = [];
}
}
public function deleteAll()
{
if (count($this->commandRowId) > 0) {
CommandHistory::whereIn('id', $this->commandRowId)->delete();
$this->myAlert([
'message' => 'Commands Deleted Deleted',
'alert-type' => 'error'
]);
$this->commandRowId = [];
} else {
$this->myAlert([
'message' => 'Please Select First',
'alert-type' => 'error'
]);
}
}
}
Step 4: Create Blade View Create a Blade view to render the Livewire component. You can customize the UI as needed. Here's a basic example:
<div>
<div class="row" >
<div class="col-md-12">
<div class="card card-outline card-info ">
<div class="card-header">
<h3 class="card-title">Run Command</h3>
</div>
<div class="card-body">
<form wire:submit.prevent="executeCommand" >
<div class="row">
<div class="col-md-12">
<div class="mb-3">
<input type="text" wire:model="command" class="form-control @error('command') is-invalid @enderror" autocomplete="off"
placeholder="Enter command...">
@error('command') <span class="text-danger">{{$message}}</span> @enderror
</div>
</div>
</div>
<div>
<button type="submit" class="btn btn-success" wire:loading.attr="disabled" wire:target="executeCommand">Run</button>
<i class="fas fa-1x fa-sync-alt fa-spin" wire:loading wire:target="executeCommand" ></i>
</div>
</form>
@if($success)
<div class="mt-3">
<h3 class="text-success">???? Command executed successfully!</h3>
<pre class="bg-dark">{{ $output }}</pre>
<p class="text-success font-weight-bold">{{ $timestamp ?? '' }}</p>
<p class="text-info font-weight-bold"> {{ $tip ?? '' }}</p>
</div>
@elseif($error)
<div class="mt-3">
<h3 class="text-danger">⚠️ An error occurred while executing the command.</h3>
<pre class="bg-dark">{!! $error !!}</pre>
<p class="text-success font-weight-bold">{{ $timestamp ?? '' }}</p>
<p class="text-info font-weight-bold"> {{ $tip ?? '' }}</p>
</div>
@endif
@if (session()->has('command'))
<div class="alert alert-dark mt-3">
<strong>Recently used: </strong>
<span id="usedCommad">{{ session('command') }}</span>
<button id="copyButton" class="btn btn-primary ml-3">Copy</button>
</div>
@endif
</div>
</div>
</div>
</div>
@if(isset($commandHistories) && count($commandHistories) > 0)
<div class="row">
<div class="col-md-12">
<div class="card card-outline card-info ">
<div class="card-header">
<h3 class="card-title">Run Terminal Commands</h3>
<div class="card-tools">
<button type="button" class="btn btn-tool" data-card-widget="collapse" title="Collapse">
<i class="fas fa-minus"></i>
</button>
</div>
</div>
<div class="card-body table-responsive p-0">
<div class="card-header ">
@hasanyrole('Super Admin|admin')
<button type="button" class="btn btn-danger btn-sm" wire:click="deleteAll" wire:loading.attr="disabled"
wire:target="deleteAll">
Delete all
</button>
<button type="button" class="btn btn-info btn-sm" wire:click="checkAll" wire:loading.attr="disabled"
wire:target="checkAll">
Check all
</button>
@else
I do not have all of these roles or have more other roles...
@endhasanyrole
<i class="fas fa-1x fa-sync-alt fa-spin" wire:loading wire:target="openCreateModel"></i>
<div class="card-tools">
<div class="input-group input-group-sm" style="width: 150px;">
<input type="text" name="table_search" class="form-control float-right"
wire:model.live="search" placeholder="Search">
<div class="input-group-append">
<button type="submit" class="btn btn-default">
<i class="fas fa-search"></i>
</button>
</div>
</div>
</div>
</div>
<table class="table table-hover text-nowrap">
<thead>
<tr>
<th scope="col">User</th>
<th scope="col">Command</th>
<th scope="col">Time</th>
<th scope="col">Status</th>
<th scope="col">Action</th>
</tr>
</thead>
<tbody>
@foreach ($commandHistories as $history)
<tr>
<th scope="row"> <input type="checkbox" wire:model="commandRowId"
value="{{$history->id}}"> {{ $history->user->name ?? 'Unknown' }}</th>
<td>
@php
$phpPath = env('IMPORT_PHP_PATH', 'D:\laravel9\php\php.exe');
$command = str_replace("$phpPath", 'php', $history->command);
$command = str_replace('"php"', 'php', $command);
@endphp
<a href="javascript:void(0)" title="{{$history->command}}"> {{ $command ?? '' }} </a>
</td>
<td>{{ $history->created_at->diffForHumans() }}</td>
<td>
@if ($history->success)
<span class="badge badge-success">Success</span>
@else
<span class="badge badge-danger">Failed</span>
@endif
</td>
<td>
<button class="btn btn-success " wire:target="copyCommand({{$history->id}})" wire:loading.attr="disabled"
wire:click.prevent="copyCommand({{$history->id}})">
<i class="fas fa-copy" wire:loading.remove wire:target="copyCommand({{ $history->id }})"></i>
<i class="fas fa-spinner fa-spin" wire:loading wire:target="copyCommand({{ $history->id }})"></i>
</button>
<button class="btn btn-danger " wire:target="delete({{$history->id}})" wire:loading.attr="disabled"
wire:click.prevent="delete({{$history->id}})">
<i class="fas fa-trash" wire:loading.remove wire:target="delete({{ $history->id }})"></i>
<i class="fas fa-spinner fa-spin" wire:loading wire:target="delete({{ $history->id }})"></i>
</button>
</td>
</tr>
@endforeach
</tbody>
</table>
</div>
</div>
</div>
</div>
@endif
<script>
document.getElementById('copyButton').addEventListener('click', function() {
var textToCopy = document.getElementById('usedCommad').innerText;
var tempTextarea = document.createElement('textarea');
tempTextarea.value = textToCopy;
document.body.appendChild(tempTextarea);
tempTextarea.select();
tempTextarea.setSelectionRange(0, 99999); // For mobile devices
document.execCommand('copy');
document.body.removeChild(tempTextarea);
var usedCommadSpan = document.getElementById('usedCommad');
usedCommadSpan.classList.add('bg-danger', 'p-2');
setTimeout(function() {
copyButton.textContent = 'Copy';
usedCommadSpan.classList.remove('bg-danger', 'p-3'); // Remove the classes
}, 2000);
Livewire.dispatch('copiedSuccess', { copiedText: textToCopy}) ;
});
document.addEventListener('livewire:init', () => {
Livewire.on('copySelectedCommand', (event) => {
let copiedCommand = event[0]['commandName'];
// console.log(copiedCommand);
var tempTextarea = document.createElement('textarea');
tempTextarea.value = copiedCommand;
document.body.appendChild(tempTextarea);
tempTextarea.select();
tempTextarea.setSelectionRange(0, 99999); // For mobile devices
document.execCommand('copy');
document.body.removeChild(tempTextarea);
Livewire.dispatch('copiedSuccess', { copiedText: copiedCommand}) ;
});
});
</script>
</div>