📋 Phase Overview
This phase focuses on building comprehensive farm operations management modules. You'll create systems for tracking watering, fertilizer applications, soil tests, air layering propagation, labor activities, and equipment management. These modules will integrate with weather data and provide detailed logging for farm operations.
End Goal: Complete farm operations tracking with integrated weather monitoring, labor management, and equipment scheduling.
⚠️ Prerequisites
- Phase 2 Completed: Plant tracking and QR system functional
- Weather API: OpenWeather API key configured
- Database Models: All existing models working correctly
- Authentication: User system operational for logging activities
Create a service to fetch and store weather data from OpenWeather API.
Create Weather Service:
# Create Weather service
php artisan make:service WeatherService
# Create Weather model and controller
php artisan make:model WeatherData
php artisan make:controller WeatherController
WeatherService (app/Services/WeatherService.php):
<?php
namespace App\Services;
use App\Models\WeatherData;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Carbon\Carbon;
class WeatherService
{
private string $apiKey;
private string $baseUrl = 'https://api.openweathermap.org/data/2.5';
private string $location;
public function __construct()
{
$this->apiKey = config('weather.openweather_api_key');
$this->location = config('weather.location', 'Booneville,AR,US');
}
public function fetchCurrentWeather(): ?array
{
try {
$response = Http::get("{$this->baseUrl}/weather", [
'q' => $this->location,
'appid' => $this->apiKey,
'units' => 'imperial'
]);
if ($response->successful()) {
return $response->json();
}
Log::error('Weather API error: ' . $response->body());
return null;
} catch (\Exception $e) {
Log::error('Weather fetch error: ' . $e->getMessage());
return null;
}
}
public function storeCurrentWeather(): bool
{
$weatherData = $this->fetchCurrentWeather();
if (!$weatherData) {
return false;
}
$today = Carbon::today();
// Check if we already have data for today
$existing = WeatherData::where('date', $today)
->where('farm_id', 1)
->first();
$data = [
'farm_id' => 1,
'date' => $today,
'source' => 'openweathermap',
'temperature_high_f' => $weatherData['main']['temp_max'],
'temperature_low_f' => $weatherData['main']['temp_min'],
'temperature_avg_f' => $weatherData['main']['temp'],
'feels_like_high_f' => $weatherData['main']['feels_like'],
'humidity_percent' => $weatherData['main']['humidity'],
'pressure_mb' => $weatherData['main']['pressure'],
'wind_speed_mph' => $weatherData['wind']['speed'] ?? null,
'wind_direction_degrees' => $weatherData['wind']['deg'] ?? null,
'cloud_cover_percent' => $weatherData['clouds']['all'] ?? null,
'uv_index' => $this->fetchUVIndex($weatherData['coord']['lat'], $weatherData['coord']['lon']),
'data_quality' => 'api_current'
];
if ($existing) {
$existing->update($data);
} else {
WeatherData::create($data);
}
return true;
}
private function fetchUVIndex(float $lat, float $lon): ?float
{
try {
$response = Http::get("{$this->baseUrl}/uvi", [
'lat' => $lat,
'lon' => $lon,
'appid' => $this->apiKey
]);
if ($response->successful()) {
$data = $response->json();
return $data['value'] ?? null;
}
} catch (\Exception $e) {
Log::error('UV Index fetch error: ' . $e->getMessage());
}
return null;
}
public function get5DayForecast(): ?array
{
try {
$response = Http::get("{$this->baseUrl}/forecast", [
'q' => $this->location,
'appid' => $this->apiKey,
'units' => 'imperial'
]);
return $response->successful() ? $response->json() : null;
} catch (\Exception $e) {
Log::error('Forecast fetch error: ' . $e->getMessage());
return null;
}
}
}
Add Weather Configuration (config/weather.php):
<?php
return [
'openweather_api_key' => env('OPENWEATHER_API_KEY'),
'location' => env('WEATHER_CITY', 'Booneville,AR,US'),
'refresh_interval' => env('WEATHER_REFRESH_INTERVAL', 600000), // 10 minutes
'freeze_warning_temp' => 32, // Fahrenheit
'frost_warning_temp' => 36,
];
Create comprehensive watering tracking with weather integration.
Create Watering Models and Controllers:
# Create watering log controller
php artisan make:controller WateringLogController --resource
# Create WateringLog model
php artisan make:model WateringLog
WateringLog Model (app/Models/WateringLog.php):
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class WateringLog extends Model
{
protected $table = 'watering_logs';
protected $primaryKey = 'watering_id';
protected $fillable = [
'plant_id', 'farm_area', 'watering_date', 'watering_time',
'water_amount_inches', 'water_amount_gallons', 'watering_method',
'duration_minutes', 'water_pressure_psi', 'water_source',
'water_temperature_f', 'soil_moisture_before', 'soil_moisture_after',
'penetration_depth_inches', 'runoff_observed', 'weather_conditions',
'irrigation_efficiency_rating', 'notes', 'watered_by'
];
protected $casts = [
'watering_date' => 'date',
'watering_time' => 'time',
'water_amount_inches' => 'decimal:2',
'water_amount_gallons' => 'decimal:2',
'duration_minutes' => 'integer',
'water_pressure_psi' => 'decimal:1',
'water_temperature_f' => 'decimal:1',
'penetration_depth_inches' => 'decimal:2',
'runoff_observed' => 'boolean'
];
public function plant(): BelongsTo
{
return $this->belongsTo(Plant::class, 'plant_id', 'plant_id');
}
public function getEfficiencyScore(): int
{
$score = 100;
if ($this->runoff_observed) {
$score -= 20;
}
if ($this->irrigation_efficiency_rating === 'poor') {
$score -= 30;
} elseif ($this->irrigation_efficiency_rating === 'fair') {
$score -= 15;
}
return max($score, 0);
}
}
WateringLogController (app/Http/Controllers/WateringLogController.php):
<?php
namespace App\Http\Controllers;
use App\Models\WateringLog;
use App\Models\Plant;
use App\Services\WeatherService;
use Illuminate\Http\Request;
use Carbon\Carbon;
class WateringLogController extends Controller
{
protected WeatherService $weatherService;
public function __construct(WeatherService $weatherService)
{
$this->weatherService = $weatherService;
}
public function index(Request $request)
{
$logs = WateringLog::with('plant')
->when($request->plant_id, function($query, $plantId) {
return $query->where('plant_id', $plantId);
})
->when($request->date_from, function($query, $dateFrom) {
return $query->whereDate('watering_date', '>=', $dateFrom);
})
->when($request->date_to, function($query, $dateTo) {
return $query->whereDate('watering_date', '<=', $dateTo);
})
->orderByDesc('watering_date')
->orderByDesc('watering_time')
->paginate(20);
$plants = Plant::where('status', 'active')->get();
return view('watering.index', compact('logs', 'plants'));
}
public function create(Request $request)
{
$plants = Plant::where('status', 'active')->get();
$selectedPlant = $request->plant_id ?
Plant::find($request->plant_id) : null;
// Get current weather for context
$weather = $this->weatherService->fetchCurrentWeather();
return view('watering.create', compact('plants', 'selectedPlant', 'weather'));
}
public function store(Request $request)
{
$validated = $request->validate([
'plant_id' => 'nullable|exists:plants,plant_id',
'farm_area' => 'nullable|string|max:50',
'watering_date' => 'required|date',
'watering_time' => 'nullable|date_format:H:i',
'water_amount_inches' => 'nullable|numeric|min:0|max:10',
'water_amount_gallons' => 'nullable|numeric|min:0',
'watering_method' => 'required|in:drip,sprinkler,hand,soaker_hose,overhead',
'duration_minutes' => 'nullable|integer|min:1',
'water_source' => 'nullable|in:municipal,well,rain_collection,pond',
'soil_moisture_before' => 'nullable|in:dry,slightly_dry,moist,wet,saturated',
'soil_moisture_after' => 'nullable|in:dry,slightly_dry,moist,wet,saturated',
'runoff_observed' => 'boolean',
'irrigation_efficiency_rating' => 'nullable|in:excellent,good,fair,poor',
'notes' => 'nullable|string|max:1000'
]);
$validated['watered_by'] = auth()->user()->name;
$validated['watering_time'] = $validated['watering_time'] ?: now()->format('H:i');
// Get current weather conditions
$weather = $this->weatherService->fetchCurrentWeather();
if ($weather) {
$conditions = [
'temperature' => $weather['main']['temp'] . '°F',
'humidity' => $weather['main']['humidity'] . '%',
'wind' => $weather['wind']['speed'] . ' mph',
'conditions' => $weather['weather'][0]['description']
];
$validated['weather_conditions'] = json_encode($conditions);
}
WateringLog::create($validated);
return redirect()->route('watering.index')
->with('success', 'Watering log recorded successfully!');
}
public function bulkCreate(Request $request)
{
$plants = Plant::where('status', 'active');
if ($request->area) {
$plants->where('location_row', $request->area);
}
$plants = $plants->get();
return view('watering.bulk-create', compact('plants'));
}
public function bulkStore(Request $request)
{
$validated = $request->validate([
'plant_ids' => 'required|array',
'plant_ids.*' => 'exists:plants,plant_id',
'watering_date' => 'required|date',
'watering_method' => 'required|in:drip,sprinkler,hand,soaker_hose,overhead',
'duration_minutes' => 'nullable|integer|min:1',
'water_amount_inches' => 'nullable|numeric|min:0',
'notes' => 'nullable|string'
]);
$weather = $this->weatherService->fetchCurrentWeather();
$weatherConditions = null;
if ($weather) {
$weatherConditions = json_encode([
'temperature' => $weather['main']['temp'] . '°F',
'humidity' => $weather['main']['humidity'] . '%',
'conditions' => $weather['weather'][0]['description']
]);
}
$created = 0;
foreach ($validated['plant_ids'] as $plantId) {
WateringLog::create([
'plant_id' => $plantId,
'watering_date' => $validated['watering_date'],
'watering_time' => now()->format('H:i'),
'watering_method' => $validated['watering_method'],
'duration_minutes' => $validated['duration_minutes'],
'water_amount_inches' => $validated['water_amount_inches'],
'weather_conditions' => $weatherConditions,
'notes' => $validated['notes'],
'watered_by' => auth()->user()->name
]);
$created++;
}
return redirect()->route('watering.index')
->with('success', "Watering logged for {$created} plants successfully!");
}
}
Create system for tracking fertilizer applications with soil pH monitoring.
Create Fertilizer Controller and Model:
# Create fertilizer controller
php artisan make:controller FertilizerApplicationController --resource
# FertilizerApplication model should already exist
FertilizerApplication Model Enhancement:
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class FertilizerApplication extends Model
{
protected $table = 'fertilizer_applications';
protected $primaryKey = 'application_id';
protected $fillable = [
'plant_id', 'farm_area', 'application_date', 'fertilizer_brand',
'fertilizer_type', 'npk_ratio', 'micronutrients', 'application_rate_per_plant',
'application_rate_per_sqft', 'total_amount_used', 'application_method',
'dilution_ratio', 'soil_ph_before', 'soil_ph_after', 'weather_conditions',
'soil_temperature_f', 'cost_per_application', 'total_cost', 'supplier',
'batch_number', 'expiration_date', 'application_equipment', 'notes', 'applied_by'
];
protected $casts = [
'application_date' => 'date',
'expiration_date' => 'date',
'soil_ph_before' => 'decimal:2',
'soil_ph_after' => 'decimal:2',
'soil_temperature_f' => 'decimal:1',
'cost_per_application' => 'decimal:2',
'total_cost' => 'decimal:2',
'micronutrients' => 'array'
];
public function plant(): BelongsTo
{
return $this->belongsTo(Plant::class, 'plant_id', 'plant_id');
}
public function getPHChangeAttribute(): ?float
{
if ($this->soil_ph_before && $this->soil_ph_after) {
return round($this->soil_ph_after - $this->soil_ph_before, 2);
}
return null;
}
public function isExpired(): bool
{
return $this->expiration_date && $this->expiration_date->isPast();
}
public function getCostPerPlantAttribute(): ?float
{
if ($this->total_cost && $this->plant_id) {
return $this->total_cost;
}
return null;
}
}
Create comprehensive air layering tracking to manage plant propagation.
Create Air Layering Controller:
# Create air layering controller
php artisan make:controller AirLayerController --resource
AirLayer Model Enhancement:
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Carbon\Carbon;
class AirLayer extends Model
{
protected $table = 'air_layers';
protected $primaryKey = 'layer_id';
protected $fillable = [
'parent_plant_id', 'layer_date_started', 'expected_separation_date',
'actual_separation_date', 'success', 'new_plant_id', 'rooting_method',
'rooting_hormone_used', 'rooting_medium', 'moisture_retention_method',
'location_on_cane', 'cane_diameter_mm', 'success_rate_expected_percent',
'root_development_stage', 'separation_readiness', 'success_notes',
'failure_reason', 'lessons_learned'
];
protected $casts = [
'layer_date_started' => 'date',
'expected_separation_date' => 'date',
'actual_separation_date' => 'date',
'success' => 'boolean',
'cane_diameter_mm' => 'decimal:2',
'success_rate_expected_percent' => 'decimal:1'
];
public function parentPlant(): BelongsTo
{
return $this->belongsTo(Plant::class, 'parent_plant_id', 'plant_id');
}
public function newPlant(): BelongsTo
{
return $this->belongsTo(Plant::class, 'new_plant_id', 'plant_id');
}
public function getDaysInProgressAttribute(): int
{
$endDate = $this->actual_separation_date ?: now();
return $this->layer_date_started->diffInDays($endDate);
}
public function getProgressStatusAttribute(): string
{
if ($this->actual_separation_date) {
return $this->success ? 'completed_success' : 'completed_failed';
}
if ($this->expected_separation_date && $this->expected_separation_date->isPast()) {
return 'overdue';
}
$daysProgress = $this->getDaysInProgressAttribute();
if ($daysProgress < 30) {
return 'early_stage';
} elseif ($daysProgress < 60) {
return 'developing';
} else {
return 'ready_for_evaluation';
}
}
public function getSuccessRateForMethod(): float
{
$totalLayers = static::where('rooting_method', $this->rooting_method)
->whereNotNull('actual_separation_date')
->count();
if ($totalLayers === 0) {
return 0;
}
$successfulLayers = static::where('rooting_method', $this->rooting_method)
->whereNotNull('actual_separation_date')
->where('success', true)
->count();
return round(($successfulLayers / $totalLayers) * 100, 1);
}
public function scopeActive($query)
{
return $query->whereNull('actual_separation_date');
}
public function scopeReadyForSeparation($query)
{
return $query->active()
->where('separation_readiness', 'ready')
->orWhere('expected_separation_date', '<=', now());
}
}
AirLayerController:
<?php
namespace App\Http\Controllers;
use App\Models\AirLayer;
use App\Models\Plant;
use Illuminate\Http\Request;
class AirLayerController extends Controller
{
public function index(Request $request)
{
$layers = AirLayer::with(['parentPlant', 'newPlant'])
->when($request->status, function($query, $status) {
if ($status === 'active') {
return $query->active();
} elseif ($status === 'completed') {
return $query->whereNotNull('actual_separation_date');
} elseif ($status === 'ready') {
return $query->readyForSeparation();
}
})
->when($request->parent_plant_id, function($query, $plantId) {
return $query->where('parent_plant_id', $plantId);
})
->orderByDesc('layer_date_started')
->paginate(20);
$parentPlants = Plant::where('status', 'active')
->has('children') // Plants that have been used for layering
->orWhereIn('plant_id', AirLayer::pluck('parent_plant_id'))
->get();
// Statistics
$stats = [
'total_active' => AirLayer::active()->count(),
'ready_for_separation' => AirLayer::readyForSeparation()->count(),
'success_rate' => $this->calculateOverallSuccessRate(),
'total_new_plants' => AirLayer::where('success', true)->count()
];
return view('air-layers.index', compact('layers', 'parentPlants', 'stats'));
}
public function create(Request $request)
{
$parentPlants = Plant::where('status', 'active')->get();
$selectedParent = $request->parent_plant_id ?
Plant::find($request->parent_plant_id) : null;
return view('air-layers.create', compact('parentPlants', 'selectedParent'));
}
public function store(Request $request)
{
$validated = $request->validate([
'parent_plant_id' => 'required|exists:plants,plant_id',
'layer_date_started' => 'required|date|before_or_equal:today',
'expected_separation_date' => 'nullable|date|after:layer_date_started',
'rooting_method' => 'required|in:air_layer,ground_layer,mound_layer,trench_layer',
'rooting_hormone_used' => 'nullable|string|max:100',
'rooting_medium' => 'required|in:sphagnum_moss,peat_moss,coconut_coir,vermiculite,perlite,mixed',
'moisture_retention_method' => 'required|in:plastic_wrap,aluminum_foil,moisture_dome,regular_watering',
'location_on_cane' => 'nullable|in:tip,middle,base,node_location',
'cane_diameter_mm' => 'nullable|numeric|min:1|max:50',
'success_rate_expected_percent' => 'nullable|numeric|min:0|max:100',
'notes' => 'nullable|string|max:1000'
]);
// Set expected separation date if not provided (typically 6-8 weeks)
if (!$validated['expected_separation_date']) {
$validated['expected_separation_date'] = Carbon::parse($validated['layer_date_started'])
->addWeeks(7);
}
AirLayer::create($validated);
return redirect()->route('air-layers.index')
->with('success', 'Air layer started successfully!');
}
public function update(Request $request, AirLayer $airLayer)
{
$validated = $request->validate([
'actual_separation_date' => 'nullable|date|after_or_equal:layer_date_started',
'success' => 'required|boolean',
'new_plant_id' => 'nullable|string|max:50|unique:plants,plant_id',
'root_development_stage' => 'nullable|in:none,minimal,developing,well_developed,extensive',
'separation_readiness' => 'nullable|in:not_ready,almost_ready,ready,overdue',
'success_notes' => 'nullable|string|max:1000',
'failure_reason' => 'nullable|string|max:500',
'lessons_learned' => 'nullable|string|max:1000'
]);
// If successful and new plant ID provided, create the new plant
if ($validated['success'] && $validated['new_plant_id']) {
Plant::create([
'plant_id' => $validated['new_plant_id'],
'farm_id' => $airLayer->parentPlant->farm_id,
'variety_id' => $airLayer->parentPlant->variety_id,
'parent_plant_id' => $airLayer->parent_plant_id,
'propagation_method' => 'air_layering',
'planted_date' => $validated['actual_separation_date'] ?? now(),
'status' => 'active',
'source' => 'air_layering',
'notes' => "Air layered from {$airLayer->parent_plant_id} on " .
($validated['actual_separation_date'] ?? now()->format('Y-m-d'))
]);
}
$airLayer->update($validated);
return redirect()->route('air-layers.show', $airLayer)
->with('success', 'Air layer updated successfully!');
}
private function calculateOverallSuccessRate(): float
{
$total = AirLayer::whereNotNull('actual_separation_date')->count();
if ($total === 0) return 0;
$successful = AirLayer::where('success', true)->count();
return round(($successful / $total) * 100, 1);
}
}
Create systems for tracking labor activities and equipment usage.
Create Controllers:
# Create labor and equipment controllers
php artisan make:controller LaborLogController --resource
php artisan make:controller EquipmentController --resource
Labor Log Features:
// Key features to implement:
- Time tracking (start/end times, breaks)
- Task categorization (planting, harvesting, maintenance, etc.)
- Worker skill levels and rates
- Area/plant assignments
- Weather conditions during work
- Productivity and quality ratings
- Cost calculations (regular + overtime)
- Safety incident tracking
- Equipment usage logging
Equipment Management Features:
// Key features to implement:
- Equipment inventory and specifications
- Maintenance scheduling and tracking
- Usage hours and cost per hour
- Purchase and warranty information
- Location tracking
- Condition assessments
- Depreciation calculations
- Repair history and costs
- Rental vs owned equipment tracking
Create comprehensive soil testing and analysis tracking.
Create Soil Test Controller:
# Create soil test controller
php artisan make:controller SoilTestController --resource
SoilTest Model Enhancement:
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Carbon\Carbon;
class SoilTest extends Model
{
protected $table = 'soil_tests';
protected $primaryKey = 'test_id';
protected $fillable = [
'farm_id', 'test_date', 'location_description', 'sample_depth_inches',
'sample_size', 'ph_level', 'ph_buffer', 'nitrogen_ppm', 'phosphorus_ppm',
'potassium_ppm', 'calcium_ppm', 'magnesium_ppm', 'sulfur_ppm',
'iron_ppm', 'manganese_ppm', 'zinc_ppm', 'copper_ppm', 'boron_ppm',
'organic_matter_percent', 'cation_exchange_capacity', 'base_saturation_percent',
'soil_texture', 'drainage_assessment', 'compaction_level', 'earthworm_count',
'microbial_activity', 'testing_lab', 'test_method', 'recommendations',
'cost', 'follow_up_date'
];
protected $casts = [
'test_date' => 'date',
'follow_up_date' => 'date',
'ph_level' => 'decimal:2',
'nitrogen_ppm' => 'decimal:1',
'phosphorus_ppm' => 'decimal:1',
'potassium_ppm' => 'decimal:1',
'organic_matter_percent' => 'decimal:2',
'cost' => 'decimal:2'
];
public function getPhStatusAttribute(): string
{
if (!$this->ph_level) return 'unknown';
if ($this->ph_level < 5.5) return 'very_acidic';
if ($this->ph_level < 6.0) return 'acidic';
if ($this->ph_level < 6.5) return 'slightly_acidic';
if ($this->ph_level < 7.5) return 'neutral';
if ($this->ph_level < 8.0) return 'slightly_alkaline';
return 'alkaline';
}
public function getNutrientStatusAttribute(): array
{
return [
'nitrogen' => $this->evaluateNutrientLevel($this->nitrogen_ppm, [20, 40]),
'phosphorus' => $this->evaluateNutrientLevel($this->phosphorus_ppm, [30, 60]),
'potassium' => $this->evaluateNutrientLevel($this->potassium_ppm, [150, 300]),
];
}
private function evaluateNutrientLevel(?float $level, array $thresholds): string
{
if (!$level) return 'unknown';
if ($level < $thresholds[0]) return 'low';
if ($level < $thresholds[1]) return 'adequate';
return 'high';
}
public function isFollowUpDue(): bool
{
return $this->follow_up_date && $this->follow_up_date->isPast();
}
}
Create weather dashboard with alerts and historical data.
Create Weather Command for Data Collection:
# Create Artisan command for weather data collection
php artisan make:command FetchWeatherData
Weather Command (app/Console/Commands/FetchWeatherData.php):
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use App\Services\WeatherService;
class FetchWeatherData extends Command
{
protected $signature = 'weather:fetch';
protected $description = 'Fetch current weather data and store in database';
public function handle(WeatherService $weatherService): int
{
$this->info('Fetching weather data...');
if ($weatherService->storeCurrentWeather()) {
$this->info('Weather data fetched and stored successfully.');
return 0;
}
$this->error('Failed to fetch weather data.');
return 1;
}
}
Schedule Weather Data Collection (app/Console/Kernel.php):
protected function schedule(Schedule $schedule)
{
// Fetch weather data every hour
$schedule->command('weather:fetch')->hourly();
// Generate weather alerts twice daily
$schedule->command('weather:alerts')->dailyAt('06:00');
$schedule->command('weather:alerts')->dailyAt('18:00');
}
✅ Phase 3 Completion Checklist
- Weather service integrated with OpenWeather API
- Watering log system with weather context
- Bulk watering operations for multiple plants
- Fertilizer application tracking with pH monitoring
- Air layering management system with success tracking
- Labor logging with time and cost tracking
- Equipment management and maintenance scheduling
- Soil test recording and analysis
- Weather dashboard with historical data
- Automated weather data collection via cron jobs
🎯 Next Steps: Phase 4 Preparation
With Phase 3 complete, you're ready for Phase 4: Harvest & Production. The next phase will involve:
- Harvest recording with quality metrics
- Yield tracking and analysis
- Value-added product management
- Production batch tracking
- Sales and customer management