Simple PHP Script to Generate a Sunrise and Sunset Calendar for Photographers
As a local photographer, I always check the sunrise and sunset times for a particular date. This PHP script can be added to any typical web server that supports PHP, such as one running WordPress. Copy and add it to a directory, then call it using the provided instructions.
You can subscribe to it from any calendar application, such as Google Calendar, Fantastical, etc., and it will output the sunset and sunrise times for your selected location. You can also get the code from my GitHub repo.
Likewise, you can install and add this script to your server. Or you can use this PHP script directly from my server. If you use it from my server, please give credit back in any application or website where it’s used. Detailed instructions are below. But as an example, if you want to subscribe to a sunrise and sunset calendar for Myrtle Beach, SC, you would subscribe to the following URL in your calendar application. You can change the latitude, longitude, and timezone to your location.
https://shorelinewebdesigns.com/wp-content/uploads/sunset/sunrise-sunset.php?lat=33.6891&lon=-78.8867&tz=America/New_York
Example Calendar for Myrtle Beach, SC
Instructions for Sunrise and Sunset ICS File Generator
The script generates an iCalendar feed when accessed via a URL containing specific parameters. Be sure to replace the shorelinewebdesigns.com link with the location on your own server.
URL Format Components:
- Base URL: The location of your script on the server.
https://shorelinewebdesigns.com/wp-content/uploads/sunset/sunrise-sunset.php - Query Separator: A question mark
?. - Parameters: Key-value pairs separated by ampersands
&.lat=LATITUDE(e.g.,lat=33.6891)lon=LONGITUDE(e.g.,lon=-78.8867)tz=TIMEZONE_ID(e.g.,tz=America/New_York)
Constructing the Full URL:
Combine the base URL and the required parameters. Ensure you use the correct values for LATITUDE, LONGITUDE, and TIMEZONE_ID.
Required Parameters:
lat: Latitude (-90 to 90).lon: Longitude (-180 to 180).tz: A valid PHP Timezone Identifier. See: PHP Supported Timezones
Example URLs:
Carefully copy the entire line for the URL you need:
- Myrtle Beach, SC, USA:
https://shorelinewebdesigns.com/wp-content/uploads/sunset/sunrise-sunset.php?lat=33.6891&lon=-78.8867&tz=America/New_York - London, UK:
https://shorelinewebdesigns.com/wp-content/uploads/sunset/sunrise-sunset.php?lat=51.5074&lon=-0.1278&tz=Europe/London - Sydney, Australia:
https://shorelinewebdesigns.com/wp-content/uploads/sunset/sunrise-sunset.php?lat=-33.8688&lon=151.2093&tz=Australia/Sydney
Subscribing in Calendar Apps:
- Construct the full URL for the desired location as shown in the examples.
- Open your calendar application (Fantastical, Google Calendar, Apple Calendar, etc.).
- Find the option to add a new calendar subscription (often called “Add Subscription Calendar”, “Add Calendar by URL”, “Add from URL”, etc.).
- Paste the complete URL you constructed into the subscription field. Make sure there are no extra spaces or line breaks.
- Configure the calendar name, color, and refresh frequency (e.g., “Every Day” or “Every Week” is suitable).
- Save the subscription.
Your calendar application will now periodically fetch data from the URL, keeping the sunrise and sunset times updated.
<?php
// --- Configuration ---
$calculation_days = 365; // How many days into the future to calculate
$event_duration_minutes = 1; // Duration of the calendar event in minutes
$prod_id = '-//Shoreline Web Designs//Sunrise Sunset Calendar V6//EN';
$calendar_name = 'Sunrise/Sunset';
$uid_domain = 'shorelinewebdesigns.com';
// --- Get Input Parameters ---
$lat = isset($_GET['lat']) ? filter_var($_GET['lat'], FILTER_VALIDATE_FLOAT) : null;
$lon = isset($_GET['lon']) ? filter_var($_GET['lon'], FILTER_VALIDATE_FLOAT) : null;
$timezone_identifier = isset($_GET['tz']) ? trim($_GET['tz']) : null;
// --- Validate Input ---
if ($lat === null || $lat === false || $lat < -90 || $lat > 90) {
header("HTTP/1.1 400 Bad Request");
die("Error: Invalid or missing 'lat' parameter. Must be a number between -90 and 90.");
}
if ($lon === null || $lon === false || $lon < -180 || $lon > 180) {
header("HTTP/1.1 400 Bad Request");
die("Error: Invalid or missing 'lon' parameter. Must be a number between -180 and 180.");
}
if (empty($timezone_identifier) || !in_array($timezone_identifier, timezone_identifiers_list())) {
header("HTTP/1.1 400 Bad Request");
die("Error: Invalid or missing 'tz' parameter. Please provide a valid PHP timezone identifier.");
}
// --- Set Timezone ---
try {
$tz = new DateTimeZone($timezone_identifier);
date_default_timezone_set($timezone_identifier);
} catch (Exception $e) {
header("HTTP/1.1 500 Internal Server Error");
die("Error: Could not process timezone identifier. " . $e->getMessage());
}
// --- Helper Functions ---
function escapeICS($string) {
return preg_replace('/([\,;])/','\\\$1', str_replace("\n", "\\n", $string));
}
function formatOffset($offsetSeconds) {
$sign = ($offsetSeconds < 0) ? '-' : '+';
$absOffset = abs($offsetSeconds);
$hours = str_pad(floor($absOffset / 3600), 2, '0', STR_PAD_LEFT);
$minutes = str_pad(floor(($absOffset % 3600) / 60), 2, '0', STR_PAD_LEFT);
return $sign . $hours . $minutes;
}
// --- VTIMEZONE Generation ---
function generateVTimezone($tzId) {
try {
$timezone = new DateTimeZone($tzId);
$start = new DateTime('now', $timezone);
$year = (int)$start->format('Y');
$transitions = $timezone->getTransitions(strtotime(($year - 1).'-01-01'), strtotime(($year + 1).'-12-31'));
if (count($transitions) <= 1) {
$transition = $transitions[0] ?? $timezone->getTransitions(time(), time())[0];
if (!$transition) return '';
$vtz = [
'BEGIN:VTIMEZONE', 'TZID:' . $tzId, 'BEGIN:STANDARD',
'DTSTART:' . date('Ymd\THis', $transition['ts']),
'TZOFFSETFROM:' . formatOffset($transition['offset']),
'TZOFFSETTO:' . formatOffset($transition['offset']),
'TZNAME:' . $transition['abbr'],
'END:STANDARD', 'END:VTIMEZONE'
];
return implode("\r\n", $vtz);
}
$std = null; $dst = null;
for ($i = count($transitions) - 1; $i >= 0; $i--) {
$t = $transitions[$i];
$fromOffset = ($i > 0) ? $transitions[$i-1]['offset'] : $t['offset'];
if ($t['isdst']) {
if ($dst === null) { $dst = $t; $dst['offsetfrom'] = $fromOffset; }
} else {
if ($std === null) { $std = $t; $std['offsetfrom'] = $fromOffset; }
}
if ($std !== null && $dst !== null) break;
}
if ($std === null && $dst !== null) $std = $dst;
if ($dst === null && $std !== null) $dst = $std;
if ($std === null && $dst === null) return '';
$vtz = ['BEGIN:VTIMEZONE', 'TZID:' . $tzId];
if ($std) {
$vtz[] = 'BEGIN:STANDARD';
$vtz[] = 'DTSTART:' . date('Ymd\THis', $std['ts'] ?: strtotime('1970-01-01 00:00:00', 0));
$vtz[] = 'TZOFFSETFROM:' . formatOffset($std['offsetfrom']);
$vtz[] = 'TZOFFSETTO:' . formatOffset($std['offset']);
$vtz[] = 'TZNAME:' . $std['abbr'];
$vtz[] = 'END:STANDARD';
}
if ($dst && $dst['isdst']) {
$vtz[] = 'BEGIN:DAYLIGHT';
$vtz[] = 'DTSTART:' . date('Ymd\THis', $dst['ts'] ?: strtotime('1970-03-08 02:00:00', 0));
$vtz[] = 'TZOFFSETFROM:' . formatOffset($dst['offsetfrom']);
$vtz[] = 'TZOFFSETTO:' . formatOffset($dst['offset']);
$vtz[] = 'TZNAME:' . $dst['abbr'];
$vtz[] = 'END:DAYLIGHT';
}
$vtz[] = 'END:VTIMEZONE';
return implode("\r\n", $vtz);
} catch (Exception $e) {
error_log("Error generating VTIMEZONE for $tzId: " . $e->getMessage());
return '';
}
}
// --- iCalendar Generation ---
$calendar_description = "Sunrise/Sunset times for Lat: {$lat}, Lon: {$lon} ({$timezone_identifier}).";
$calendar_name_location = escapeICS($calendar_name . " (" . round($lat,2) . ", " . round($lon,2) . ")");
$ics_content = [
'BEGIN:VCALENDAR',
'VERSION:2.0',
'PRODID:' . $prod_id,
'CALSCALE:GREGORIAN'
];
$vtimezone_block = generateVTimezone($timezone_identifier);
if (!empty($vtimezone_block)) {
$ics_content[] = $vtimezone_block;
} else {
error_log("Warning: VTIMEZONE block could not be generated for {$timezone_identifier}.");
}
$ics_content[] = 'X-WR-CALNAME:' . $calendar_name_location;
$ics_content[] = 'X-WR-TIMEZONE:' . $timezone_identifier;
$ics_content[] = 'X-WR-CALDESC:' . escapeICS($calendar_description);
$start_date = new DateTime('now', $tz);
// Set time to noon to avoid edge cases with DST shifts skipping events
$start_date->setTime(12, 0);
for ($i = 0; $i < $calculation_days; $i++) {
$current_timestamp = $start_date->getTimestamp();
$day_str = $start_date->format('Ymd');
// Modern PHP 8.1+ Sun Info Function
$sun_info = date_sun_info($current_timestamp, $lat, $lon);
// Create Sunrise Event
if (isset($sun_info['sunrise']) && is_numeric($sun_info['sunrise'])) {
$dtstart_local = date('Ymd\THis', $sun_info['sunrise']);
$event_uid = 'sunrise-' . $day_str . '-' . abs(round($lat)) . '-' . abs(round($lon)) . '@' . $uid_domain;
$ics_content[] = 'BEGIN:VEVENT';
$ics_content[] = 'UID:' . $event_uid;
$ics_content[] = 'DTSTAMP:' . gmdate('Ymd\THis\Z');
if (!empty($vtimezone_block)) {
$ics_content[] = 'DTSTART;TZID=' . $timezone_identifier . ':' . $dtstart_local;
} else {
$ics_content[] = 'DTSTART:' . gmdate('Ymd\THis\Z', $sun_info['sunrise']);
}
$ics_content[] = 'DURATION:PT' . $event_duration_minutes . 'M';
$ics_content[] = 'SUMMARY:Sunrise';
$ics_content[] = 'END:VEVENT';
}
// Create Sunset Event
if (isset($sun_info['sunset']) && is_numeric($sun_info['sunset'])) {
$dtstart_local = date('Ymd\THis', $sun_info['sunset']);
$event_uid = 'sunset-' . $day_str . '-' . abs(round($lat)) . '-' . abs(round($lon)) . '@' . $uid_domain;
$ics_content[] = 'BEGIN:VEVENT';
$ics_content[] = 'UID:' . $event_uid;
$ics_content[] = 'DTSTAMP:' . gmdate('Ymd\THis\Z');
if (!empty($vtimezone_block)) {
$ics_content[] = 'DTSTART;TZID=' . $timezone_identifier . ':' . $dtstart_local;
} else {
$ics_content[] = 'DTSTART:' . gmdate('Ymd\THis\Z', $sun_info['sunset']);
}
$ics_content[] = 'DURATION:PT' . $event_duration_minutes . 'M';
$ics_content[] = 'SUMMARY:Sunset';
$ics_content[] = 'END:VEVENT';
}
// Move to the next day safely
$start_date->modify('+1 day');
}
$ics_content[] = 'END:VCALENDAR';
// --- Output ICS File ---
$filename = "sunrise_sunset_" . str_replace('/', '_', $timezone_identifier) . "_" . round($lat, 2) . "_" . round($lon, 2) . ".ics";
header('Content-Type: text/calendar; charset=utf-8');
header('Content-Disposition: attachment; filename="' . $filename . '"');
header('Pragma: no-cache');
header('Expires: 0');
echo implode("\r\n", $ics_content);
?>